@michaelxwb/code-flow 0.6.4 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -3
- package/docs/USAGE.md +1529 -0
- package/docs/acceptance-contract.md +217 -0
- package/docs/ci/acceptance-evidence.md +77 -0
- package/docs/migrations/runtime-0.7.0.md +41 -0
- package/docs/migrations/runtime-0.8.0.md +154 -0
- package/docs/migrations/runtime-0.9.0.md +40 -0
- package/docs/spec-injection-hardening.md +54 -0
- package/docs/spec-injection-performance.json +73 -0
- package/docs/spec-optimization.md +12 -0
- package/package.json +18 -3
- package/src/adapters/claude/CLAUDE.md +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -3
- package/src/adapters/claude/commands/cf-spec.md +23 -3
- package/src/adapters/claude/commands/cf-stats.md +11 -1
- package/src/adapters/claude/commands/cf-sync.md +20 -6
- package/src/adapters/claude/commands/cf-task/align.md +25 -3
- package/src/adapters/claude/commands/cf-task/archive.md +36 -40
- package/src/adapters/claude/commands/cf-task/block.md +15 -2
- package/src/adapters/claude/commands/cf-task/graph.md +13 -1
- package/src/adapters/claude/commands/cf-task/note.md +13 -1
- package/src/adapters/claude/commands/cf-task/plan.md +137 -23
- package/src/adapters/claude/commands/cf-task/prd.md +13 -2
- package/src/adapters/claude/commands/cf-task/start.md +67 -35
- package/src/adapters/claude/commands/cf-task/status.md +12 -0
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/claude/commands/cf-validate.md +30 -5
- package/src/adapters/codex/AGENTS.md +1 -1
- package/src/adapters/codex/hooks.json +14 -10
- package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
- package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
- package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -40
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +67 -35
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +73 -34
- package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
- package/src/adapters/commands.json +115 -0
- package/src/adapters/costrict/CLAUDE.md +1 -1
- package/src/adapters/costrict/commands/cf-init.md +1 -3
- package/src/adapters/costrict/commands/cf-spec.md +23 -3
- package/src/adapters/costrict/commands/cf-stats.md +11 -1
- package/src/adapters/costrict/commands/cf-sync.md +20 -6
- package/src/adapters/costrict/commands/cf-task/align.md +25 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +36 -40
- package/src/adapters/costrict/commands/cf-task/block.md +15 -2
- package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
- package/src/adapters/costrict/commands/cf-task/note.md +13 -1
- package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
- package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
- package/src/adapters/costrict/commands/cf-task/start.md +67 -35
- package/src/adapters/costrict/commands/cf-task/status.md +12 -0
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/costrict/commands/cf-validate.md +30 -5
- package/src/adapters/install-plan.json +100 -0
- package/src/adapters/opencode/AGENTS.md +1 -1
- package/src/adapters/opencode/commands/cf-init.md +7 -7
- package/src/adapters/opencode/commands/cf-learn.md +6 -6
- package/src/adapters/opencode/commands/cf-spec.md +34 -2
- package/src/adapters/opencode/commands/cf-stats.md +17 -7
- package/src/adapters/opencode/commands/cf-sync.md +21 -6
- package/src/adapters/opencode/commands/cf-task/align.md +25 -8
- package/src/adapters/opencode/commands/cf-task/archive.md +36 -40
- package/src/adapters/opencode/commands/cf-task/block.md +16 -2
- package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
- package/src/adapters/opencode/commands/cf-task/note.md +14 -1
- package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
- package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
- package/src/adapters/opencode/commands/cf-task/start.md +68 -41
- package/src/adapters/opencode/commands/cf-task/status.md +13 -0
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/opencode/commands/cf-validate.md +35 -9
- package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
- package/src/adapters/platform-bindings.json +370 -0
- package/src/cli.js +369 -316
- package/src/core/code-flow/config.yml +4 -1
- package/src/core/code-flow/runtime-commands.json +259 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
- package/src/core/code-flow/scripts/cf_audit.py +10 -0
- package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
- package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
- package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
- package/src/core/code-flow/scripts/cf_feedback.py +5 -51
- package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
- package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +5 -1714
- package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
- package/src/core/code-flow/scripts/cf_stats.py +5 -501
- package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
- package/src/core/code-flow/scripts/cf_sync.py +5 -135
- package/src/core/code-flow/scripts/cf_task_index.py +5 -143
- package/src/core/code-flow/scripts/cf_task_parallel.py +5 -380
- package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
- package/src/core/code-flow/scripts/cf_validation.py +6 -77
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
- package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
- package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
- package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
- package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
- package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +37 -3
- package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
- package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
- package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
- package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +252 -26
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
- package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
- package/src/core/code-flow/scripts/runtime-resources.json +29 -0
- package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
- package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
- package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
- package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
- package/src/core/code-flow/validation.yml +9 -3
- package/src/migrate/runtime.js +213 -0
- package/src/migrate/spec-workflow.js +16 -1
- package/src/runtime.js +219 -0
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -299
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared execution substrate: deadline-aware argv subprocess runner.
|
|
3
|
+
|
|
4
|
+
All check executors (acceptance scenarios, Spec verifiers, session
|
|
5
|
+
validators) funnel through here so one entry deadline constrains the whole
|
|
6
|
+
chain, commands never pass through a shell, and unrun work is reported as
|
|
7
|
+
`incomplete` instead of silently skipped or falsely passed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import signal
|
|
16
|
+
import shlex
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from contextvars import ContextVar
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Callable, Iterator, Mapping, Optional, Sequence
|
|
25
|
+
|
|
26
|
+
_EXECUTIONS: ContextVar[Optional[dict[str, dict[str, object]]]] = ContextVar("cf_executions", default=None)
|
|
27
|
+
_REPORTS: ContextVar[Optional[list[dict[str, object]]]] = ContextVar("cf_reports", default=None)
|
|
28
|
+
_SECRET_OPTION = re.compile(r"(?i)^--?(?:api[-_]?key|access[-_]?token|token|password|passwd|secret|credential)$")
|
|
29
|
+
_SECRET_ASSIGNMENT = re.compile(r"(?i)(--?(?:api[-_]?key|access[-_]?token|token|password|passwd|secret|credential)=)([^\s]+)")
|
|
30
|
+
_RUNTIME_ENV = frozenset(("CF_RUNTIME_COMMAND", "CF_RUNTIME_ACTION", "CF_CLI_VERSION"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def redact_sensitive_text(value: str) -> str:
|
|
34
|
+
"""Hide inherited sensitive environment values from diagnostics."""
|
|
35
|
+
names = ("SECRET", "TOKEN", "PASSWORD", "PASSWD", "CREDENTIAL", "API_KEY", "PRIVATE_KEY")
|
|
36
|
+
secrets = {item for key, item in os.environ.items()
|
|
37
|
+
if item and any(name in key.upper() for name in names)}
|
|
38
|
+
for secret in sorted(secrets, key=len, reverse=True):
|
|
39
|
+
value = value.replace(secret, "[REDACTED]")
|
|
40
|
+
return _SECRET_ASSIGNMENT.sub(r"\1[REDACTED]", value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _redact_sequence(values: Sequence[object]) -> list[object]:
|
|
44
|
+
result: list[object] = []
|
|
45
|
+
hide_next = False
|
|
46
|
+
for item in values:
|
|
47
|
+
if hide_next:
|
|
48
|
+
result.append("[REDACTED]")
|
|
49
|
+
hide_next = False
|
|
50
|
+
continue
|
|
51
|
+
result.append(redact_sensitive_payload(item))
|
|
52
|
+
hide_next = isinstance(item, str) and bool(_SECRET_OPTION.fullmatch(item))
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def redact_sensitive_payload(value: object) -> object:
|
|
57
|
+
"""Return a JSON-compatible copy with sensitive strings redacted."""
|
|
58
|
+
if isinstance(value, str):
|
|
59
|
+
return redact_sensitive_text(value)
|
|
60
|
+
if isinstance(value, Mapping):
|
|
61
|
+
return {key: redact_sensitive_payload(item) for key, item in value.items()}
|
|
62
|
+
if isinstance(value, (list, tuple)):
|
|
63
|
+
return _redact_sequence(value)
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextmanager
|
|
68
|
+
def report_session() -> Iterator[list[dict[str, object]]]:
|
|
69
|
+
reports: list[dict[str, object]] = []
|
|
70
|
+
token = _REPORTS.set(reports)
|
|
71
|
+
try:
|
|
72
|
+
yield reports
|
|
73
|
+
finally:
|
|
74
|
+
_REPORTS.reset(token)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def record_report(report: Mapping[str, object]) -> None:
|
|
78
|
+
reports = _REPORTS.get()
|
|
79
|
+
if reports is not None:
|
|
80
|
+
reports.append(dict(report))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def report_requested() -> bool:
|
|
84
|
+
return _REPORTS.get() is not None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@contextmanager
|
|
88
|
+
def execution_session() -> Iterator[None]:
|
|
89
|
+
"""Share results only inside one validation operation, never across turns."""
|
|
90
|
+
if _EXECUTIONS.get() is not None:
|
|
91
|
+
yield
|
|
92
|
+
return
|
|
93
|
+
token = _EXECUTIONS.set({})
|
|
94
|
+
try:
|
|
95
|
+
yield
|
|
96
|
+
finally:
|
|
97
|
+
_EXECUTIONS.reset(token)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def invalidate_executions() -> None:
|
|
101
|
+
cache = _EXECUTIONS.get()
|
|
102
|
+
if cache is not None:
|
|
103
|
+
cache.clear()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def command_environment(env: Optional[Mapping[str, str]] = None) -> dict[str, str]:
|
|
107
|
+
"""Do not pass CLI dispatch metadata into user checks."""
|
|
108
|
+
environment = {key: value for key, value in os.environ.items() if key not in _RUNTIME_ENV}
|
|
109
|
+
environment.update({str(key): str(value) for key, value in (env or {}).items()})
|
|
110
|
+
return environment
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def execution_key(argv: Sequence[str], cwd: str, timeout: float,
|
|
114
|
+
env: Optional[Mapping[str, str]] = None) -> str:
|
|
115
|
+
"""Identity of a command under the current process environment."""
|
|
116
|
+
environment = command_environment(env)
|
|
117
|
+
payload: list[object] = [list(argv), str(Path(cwd).resolve()), float(timeout), sorted(environment.items())]
|
|
118
|
+
return hashlib.sha256(json.dumps(payload, ensure_ascii=False).encode()).hexdigest()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def execution_environment_hash(env: Optional[Mapping[str, str]] = None) -> str:
|
|
122
|
+
"""Bind evidence to inherited and explicit environment without exposing values."""
|
|
123
|
+
environment = command_environment(env)
|
|
124
|
+
payload = json.dumps(sorted(environment.items()), ensure_ascii=False)
|
|
125
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def remaining_seconds(deadline: Optional[float]) -> Optional[float]:
|
|
129
|
+
"""Seconds left until the absolute monotonic deadline (None = unbounded)."""
|
|
130
|
+
if deadline is None:
|
|
131
|
+
return None
|
|
132
|
+
return deadline - time.monotonic()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_argv(template: str, files: Sequence[str]) -> list[str]:
|
|
136
|
+
"""Expand a validator command template into argv without a shell.
|
|
137
|
+
|
|
138
|
+
`{files}` must be a standalone token and expands to one argv entry per
|
|
139
|
+
file (spaces/metachars safe). Embedded usage is a config error — refusing
|
|
140
|
+
is safer than reintroducing shell splitting.
|
|
141
|
+
"""
|
|
142
|
+
tokens = shlex.split(template or "")
|
|
143
|
+
if not tokens:
|
|
144
|
+
return []
|
|
145
|
+
argv: list[str] = []
|
|
146
|
+
for token in tokens:
|
|
147
|
+
if token == "{files}":
|
|
148
|
+
argv.extend(files)
|
|
149
|
+
elif "{files}" in token:
|
|
150
|
+
raise ValueError(f"validator command must keep {{files}} as a standalone token: {template!r}")
|
|
151
|
+
else:
|
|
152
|
+
argv.append(token)
|
|
153
|
+
return argv
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def run_command(
|
|
157
|
+
argv: Sequence[str],
|
|
158
|
+
cwd: str,
|
|
159
|
+
timeout: float,
|
|
160
|
+
deadline: Optional[float] = None,
|
|
161
|
+
env: Optional[Mapping[str, str]] = None,
|
|
162
|
+
identity_context: Optional[Mapping[str, object]] = None,
|
|
163
|
+
) -> dict[str, object]:
|
|
164
|
+
"""Run argv synchronously under timeout+deadline. Never uses a shell."""
|
|
165
|
+
key = execution_key(argv, cwd, timeout, env)
|
|
166
|
+
if identity_context:
|
|
167
|
+
payload = json.dumps(identity_context, sort_keys=True, ensure_ascii=False, default=str)
|
|
168
|
+
key = hashlib.sha256(f"{key}:{payload}".encode()).hexdigest()
|
|
169
|
+
cache = _EXECUTIONS.get()
|
|
170
|
+
fingerprint = ""
|
|
171
|
+
if cache is not None:
|
|
172
|
+
root = _fingerprint_git(cwd, "rev-parse", "--show-toplevel")
|
|
173
|
+
if root is not None and root.returncode == 0:
|
|
174
|
+
fingerprint = worktree_fingerprint(root.stdout.strip())
|
|
175
|
+
key = hashlib.sha256(f"{key}:{fingerprint}".encode()).hexdigest() if fingerprint else ""
|
|
176
|
+
if cache is not None and key and key in cache:
|
|
177
|
+
return {**cache[key], "execution_ms": 0.0, "execution_reused": True}
|
|
178
|
+
started = time.monotonic()
|
|
179
|
+
result = _execute(argv, cwd, timeout, deadline, env)
|
|
180
|
+
result["execution_ms"] = ((time.monotonic() - started) * 1000
|
|
181
|
+
if result.get("execution_started", True) else 0.0)
|
|
182
|
+
result["execution_reused"] = False
|
|
183
|
+
if cache is not None and key and result["status"] == "ok" and result["returncode"] == 0:
|
|
184
|
+
cache[key] = result
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def hash_worktree_paths(project_root: str, paths: Sequence[str]) -> Optional[list]:
|
|
189
|
+
"""Batch-hash worktree file contents (single `git hash-object` process)."""
|
|
190
|
+
if not paths:
|
|
191
|
+
return []
|
|
192
|
+
try:
|
|
193
|
+
completed = subprocess.run(
|
|
194
|
+
("git", "-C", project_root, "hash-object", "--stdin-paths"),
|
|
195
|
+
input="\n".join(paths) + "\n", text=True, capture_output=True, timeout=5,
|
|
196
|
+
)
|
|
197
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
198
|
+
sys.stderr.write(f"worktree hashing failed: {exc}\n")
|
|
199
|
+
return None
|
|
200
|
+
if completed.returncode != 0:
|
|
201
|
+
return None
|
|
202
|
+
hashes = completed.stdout.splitlines()
|
|
203
|
+
if len(hashes) != len(paths):
|
|
204
|
+
return None
|
|
205
|
+
return [(path, sha.strip()) for path, sha in zip(paths, hashes)]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _fingerprint_git(root: str, *arguments: str) -> Optional[subprocess.CompletedProcess]:
|
|
209
|
+
try:
|
|
210
|
+
return subprocess.run(
|
|
211
|
+
("git", "-C", root, *arguments), text=True, encoding="utf-8",
|
|
212
|
+
capture_output=True, check=False, timeout=5,
|
|
213
|
+
)
|
|
214
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
215
|
+
sys.stderr.write(f"worktree fingerprint failed: {exc}\n")
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _runtime_path(path: str) -> bool:
|
|
220
|
+
return path.startswith(".code-flow/") or Path(path).name == ".acceptance-manifest.json"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _fingerprint_changes(status: str, contents: dict[str, str], include_untracked: bool,
|
|
224
|
+
untracked_filter: Optional[Callable[[str], bool]]) -> Optional[list[str]]:
|
|
225
|
+
changed: list[str] = []
|
|
226
|
+
for line in status.splitlines():
|
|
227
|
+
code, path = line[:2], line[3:].strip()
|
|
228
|
+
if " -> " in path:
|
|
229
|
+
path = path.split(" -> ", 1)[1].strip()
|
|
230
|
+
if not path or _runtime_path(path):
|
|
231
|
+
continue
|
|
232
|
+
if path.startswith('"'):
|
|
233
|
+
return None # Quoted/escaped paths cannot be identified reliably.
|
|
234
|
+
if code.strip() == "??":
|
|
235
|
+
if include_untracked and (untracked_filter is None or untracked_filter(path)):
|
|
236
|
+
changed.append(path)
|
|
237
|
+
elif "D" in code:
|
|
238
|
+
contents.pop(path, None)
|
|
239
|
+
else:
|
|
240
|
+
changed.append(path)
|
|
241
|
+
return changed
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def worktree_fingerprint(project_root: str, untracked_filter: Optional[Callable[[str], bool]] = None,
|
|
245
|
+
include_untracked: bool = True) -> str:
|
|
246
|
+
"""Hash real content including new files, excluding workflow runtime state.
|
|
247
|
+
|
|
248
|
+
Staging/committing unchanged bytes does not change identity. Ignored files
|
|
249
|
+
never participate; test outputs must be Git-ignored. Explicit check inputs
|
|
250
|
+
are hashed separately. Return an empty identity when it cannot be proven.
|
|
251
|
+
"""
|
|
252
|
+
tree = _fingerprint_git(project_root, "ls-tree", "-r", "HEAD")
|
|
253
|
+
status = _fingerprint_git(project_root, "status", "--porcelain", "--untracked-files=all")
|
|
254
|
+
if tree is None or status is None or tree.returncode != 0 or status.returncode != 0:
|
|
255
|
+
return ""
|
|
256
|
+
contents: dict[str, str] = {}
|
|
257
|
+
for line in tree.stdout.splitlines():
|
|
258
|
+
meta, _, path = line.partition("\t")
|
|
259
|
+
fields = meta.split()
|
|
260
|
+
if path and len(fields) >= 3 and not _runtime_path(path):
|
|
261
|
+
contents[path] = fields[2]
|
|
262
|
+
changed = _fingerprint_changes(status.stdout, contents, include_untracked, untracked_filter)
|
|
263
|
+
if changed is None:
|
|
264
|
+
return ""
|
|
265
|
+
hashed = hash_worktree_paths(project_root, changed)
|
|
266
|
+
if hashed is None:
|
|
267
|
+
return ""
|
|
268
|
+
for path, sha in hashed:
|
|
269
|
+
contents[path] = sha
|
|
270
|
+
digest = hashlib.sha256()
|
|
271
|
+
for path in sorted(contents):
|
|
272
|
+
digest.update(path.encode())
|
|
273
|
+
digest.update(b"\0")
|
|
274
|
+
digest.update(contents[path].encode())
|
|
275
|
+
digest.update(b"\n")
|
|
276
|
+
return digest.hexdigest()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _execute(argv: Sequence[str], cwd: str, timeout: float, deadline: Optional[float],
|
|
280
|
+
env: Optional[Mapping[str, str]] = None) -> dict[str, object]:
|
|
281
|
+
remaining = remaining_seconds(deadline)
|
|
282
|
+
if remaining is not None and remaining <= 0:
|
|
283
|
+
return {"status": "deadline_exceeded", "returncode": None, "stdout": "", "stderr": "",
|
|
284
|
+
"execution_started": False}
|
|
285
|
+
# When the entry deadline is tighter than the command timeout, expiry is
|
|
286
|
+
# unexecuted work (`incomplete`), never a product failure.
|
|
287
|
+
deadline_bounded = remaining is not None and remaining <= timeout
|
|
288
|
+
effective = min(timeout, remaining) if remaining is not None else timeout
|
|
289
|
+
child_env = command_environment(env)
|
|
290
|
+
try:
|
|
291
|
+
proc = subprocess.Popen(
|
|
292
|
+
list(argv), cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
293
|
+
env=child_env, start_new_session=os.name != "nt",
|
|
294
|
+
)
|
|
295
|
+
out, err = proc.communicate(timeout=max(effective, 0.01))
|
|
296
|
+
except subprocess.TimeoutExpired as exc:
|
|
297
|
+
return _timeout_result(proc, exc, deadline_bounded)
|
|
298
|
+
except OSError as exc:
|
|
299
|
+
return {"status": "spawn_error", "returncode": None, "stdout": "", "stderr": str(exc),
|
|
300
|
+
"execution_started": False}
|
|
301
|
+
return {"status": "ok", "returncode": proc.returncode, "stdout": out or "", "stderr": err or ""}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _timeout_result(proc: subprocess.Popen, expired: subprocess.TimeoutExpired,
|
|
305
|
+
deadline_bounded: bool) -> dict[str, object]:
|
|
306
|
+
if os.name != "nt":
|
|
307
|
+
try:
|
|
308
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
309
|
+
except ProcessLookupError:
|
|
310
|
+
pass # The process group already exited between timeout and kill.
|
|
311
|
+
else:
|
|
312
|
+
subprocess.run(("taskkill", "/F", "/T", "/PID", str(proc.pid)),
|
|
313
|
+
capture_output=True, timeout=2, check=False)
|
|
314
|
+
proc.kill()
|
|
315
|
+
try:
|
|
316
|
+
out, err = proc.communicate(timeout=1)
|
|
317
|
+
except subprocess.TimeoutExpired:
|
|
318
|
+
out, err = expired.stdout, expired.stderr
|
|
319
|
+
for pipe in (proc.stdout, proc.stderr):
|
|
320
|
+
if pipe is not None:
|
|
321
|
+
pipe.close()
|
|
322
|
+
def tail(value: object) -> str:
|
|
323
|
+
return (value.decode(errors="replace") if isinstance(value, bytes) else str(value or ""))[-4000:]
|
|
324
|
+
return {"status": "deadline_exceeded" if deadline_bounded else "timeout", "returncode": None,
|
|
325
|
+
"stdout": tail(out), "stderr": tail(err)}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""L0 filesystem primitives: text reads and atomic replacement writes."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional, Union
|
|
10
|
+
|
|
11
|
+
PathLike = Union[str, os.PathLike]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def read_text(path: PathLike) -> Optional[str]:
|
|
15
|
+
"""Read UTF-8 text; a missing file is None, other IO errors propagate."""
|
|
16
|
+
try:
|
|
17
|
+
return Path(path).read_text(encoding="utf-8")
|
|
18
|
+
except FileNotFoundError:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def write_text(path: PathLike, text: str) -> None:
|
|
23
|
+
"""Write UTF-8 text in place (non-atomic convenience writer)."""
|
|
24
|
+
Path(path).write_text(text, encoding="utf-8")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def atomic_write_text(path: PathLike, text: str) -> None:
|
|
28
|
+
"""Replace path with exact UTF-8 text via a temp file and os.replace.
|
|
29
|
+
|
|
30
|
+
On any failure the target keeps its previous content and the temp file is
|
|
31
|
+
removed before the original exception propagates. Disable native newline
|
|
32
|
+
translation so transaction snapshots also round-trip unchanged on Windows.
|
|
33
|
+
"""
|
|
34
|
+
target = Path(path)
|
|
35
|
+
temporary = ""
|
|
36
|
+
try:
|
|
37
|
+
with tempfile.NamedTemporaryFile(
|
|
38
|
+
"w",
|
|
39
|
+
encoding="utf-8",
|
|
40
|
+
newline="",
|
|
41
|
+
dir=str(target.parent),
|
|
42
|
+
prefix=f".{target.name}.",
|
|
43
|
+
suffix=".tmp",
|
|
44
|
+
delete=False,
|
|
45
|
+
) as handle:
|
|
46
|
+
temporary = handle.name
|
|
47
|
+
handle.write(text)
|
|
48
|
+
handle.flush()
|
|
49
|
+
os.fsync(handle.fileno())
|
|
50
|
+
os.replace(temporary, target)
|
|
51
|
+
except Exception:
|
|
52
|
+
if temporary:
|
|
53
|
+
try:
|
|
54
|
+
os.unlink(temporary)
|
|
55
|
+
except OSError:
|
|
56
|
+
pass # best effort: the original failure is what callers need
|
|
57
|
+
raise
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""L0 Git primitives: one implementation of the git CLI for every consumer."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import Mapping, Optional, Sequence
|
|
8
|
+
|
|
9
|
+
_STATUS_ARGV = ("status", "--porcelain=v1", "-z", "--untracked-files=all")
|
|
10
|
+
_STATUS_NO_UNTRACKED_ARGV = ("status", "--porcelain=v1", "-z", "--untracked-files=no")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GitError(RuntimeError):
|
|
14
|
+
"""Stable git failure carrying a machine code and the failing argv."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, code: str, message: str, *, argv: Sequence[str] = (),
|
|
17
|
+
returncode: Optional[int] = None) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.code = code
|
|
20
|
+
self.message = message
|
|
21
|
+
self.argv = tuple(argv)
|
|
22
|
+
self.returncode = returncode
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_git(root: str, argv: Sequence[str], *, timeout: float = 30) -> str:
|
|
26
|
+
"""Run git in root and return stdout; any failure raises GitError."""
|
|
27
|
+
try:
|
|
28
|
+
result = subprocess.run(
|
|
29
|
+
("git", *argv), cwd=root, text=True, encoding="utf-8",
|
|
30
|
+
capture_output=True, check=False, timeout=timeout,
|
|
31
|
+
)
|
|
32
|
+
except OSError as exc:
|
|
33
|
+
raise GitError("git_unavailable", str(exc), argv=argv) from exc
|
|
34
|
+
except subprocess.TimeoutExpired as exc:
|
|
35
|
+
raise GitError("git_timeout", f"git timed out after {timeout}s", argv=argv) from exc
|
|
36
|
+
if result.returncode != 0:
|
|
37
|
+
message = result.stderr.strip() or "Git command failed"
|
|
38
|
+
raise GitError("git_error", message, argv=argv, returncode=result.returncode)
|
|
39
|
+
return result.stdout
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _status_name(code: str) -> str:
|
|
43
|
+
if code == "??":
|
|
44
|
+
return "untracked"
|
|
45
|
+
if "D" in code:
|
|
46
|
+
return "deleted"
|
|
47
|
+
if "A" in code:
|
|
48
|
+
return "added"
|
|
49
|
+
if "M" in code:
|
|
50
|
+
return "modified"
|
|
51
|
+
return "changed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def changed_entries(root: str, *, include_untracked: bool = True) -> Mapping[str, str]:
|
|
55
|
+
"""Working-tree changes keyed by relative path; renames also name the old path."""
|
|
56
|
+
argv = _STATUS_ARGV if include_untracked else _STATUS_NO_UNTRACKED_ARGV
|
|
57
|
+
records = run_git(root, argv).split("\0")
|
|
58
|
+
changes: dict[str, str] = {}
|
|
59
|
+
index = 0
|
|
60
|
+
while index < len(records) and records[index]:
|
|
61
|
+
record = records[index]
|
|
62
|
+
if len(record) < 4:
|
|
63
|
+
raise GitError("invalid_git_status", record, argv=argv)
|
|
64
|
+
code, path = record[:2], record[3:]
|
|
65
|
+
index += 1
|
|
66
|
+
if "R" in code or "C" in code:
|
|
67
|
+
# porcelain -z rename/copy emits two records: "XY <new>" then "<old>"
|
|
68
|
+
if index >= len(records) or not records[index]:
|
|
69
|
+
raise GitError("invalid_git_status", f"{code} {path}", argv=argv)
|
|
70
|
+
old_path = records[index]
|
|
71
|
+
index += 1
|
|
72
|
+
changes[path] = _status_name(code)
|
|
73
|
+
changes[old_path] = "deleted"
|
|
74
|
+
continue
|
|
75
|
+
changes[path] = _status_name(code)
|
|
76
|
+
return changes
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def changed_files(root: str, *, include_untracked: bool = True) -> tuple[str, ...]:
|
|
80
|
+
"""Relative paths with uncommitted changes (working tree and index)."""
|
|
81
|
+
return tuple(changed_entries(root, include_untracked=include_untracked))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def tracked_files(root: str) -> tuple[str, ...]:
|
|
85
|
+
"""Tracked relative paths; non-git projects read as an empty set."""
|
|
86
|
+
try:
|
|
87
|
+
output = run_git(root, ("ls-files",))
|
|
88
|
+
except GitError:
|
|
89
|
+
return ()
|
|
90
|
+
return tuple(
|
|
91
|
+
line.replace("\\", "/").strip() for line in output.splitlines() if line.strip()
|
|
92
|
+
)
|
|
@@ -38,7 +38,8 @@ KNOWN_EVENTS = (
|
|
|
38
38
|
)
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
def
|
|
41
|
+
def warn(msg: str) -> None:
|
|
42
|
+
"""Write one diagnostic line to stderr; stdout stays reserved for protocol JSON."""
|
|
42
43
|
sys.stderr.write(f"[cf-log] {msg}\n")
|
|
43
44
|
|
|
44
45
|
|
|
@@ -59,9 +60,14 @@ def _rotate_if_needed(project_root: str, path: str) -> None:
|
|
|
59
60
|
now = datetime.now()
|
|
60
61
|
month_dir = _archive_dir(project_root, now.strftime("%Y-%m"))
|
|
61
62
|
os.makedirs(month_dir, exist_ok=True)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
stamp = now.strftime("%Y%m%dT%H%M%S")
|
|
64
|
+
target = os.path.join(month_dir, f"session-log-{stamp}.jsonl")
|
|
65
|
+
# Second-resolution names collide when a log rotates twice quickly; never
|
|
66
|
+
# overwrite an existing archive.
|
|
67
|
+
counter = 1
|
|
68
|
+
while os.path.exists(target):
|
|
69
|
+
counter += 1
|
|
70
|
+
target = os.path.join(month_dir, f"session-log-{stamp}-{counter}.jsonl")
|
|
65
71
|
os.replace(path, target)
|
|
66
72
|
|
|
67
73
|
|
|
@@ -85,7 +91,7 @@ def append_event(project_root: str, event: str, data: dict, sid: str) -> bool:
|
|
|
85
91
|
f.write(line + "\n")
|
|
86
92
|
return True
|
|
87
93
|
except Exception as exc:
|
|
88
|
-
|
|
94
|
+
warn(f"append_event failed: {exc}")
|
|
89
95
|
return False
|
|
90
96
|
|
|
91
97
|
|
|
@@ -112,7 +118,7 @@ def _candidate_files(project_root: str, days: int) -> list:
|
|
|
112
118
|
except ValueError:
|
|
113
119
|
continue
|
|
114
120
|
# keep a month if any part of it can fall inside the window
|
|
115
|
-
if month_start >= cutoff.replace(day=1):
|
|
121
|
+
if month_start >= cutoff.replace(day=1, hour=0, minute=0, second=0, microsecond=0):
|
|
116
122
|
month_dir = os.path.join(base, month)
|
|
117
123
|
paths.extend(
|
|
118
124
|
os.path.join(month_dir, name)
|
|
@@ -149,6 +155,6 @@ def read_events(project_root: str, days: int = 30, events: tuple = ()) -> list:
|
|
|
149
155
|
continue
|
|
150
156
|
out.append(item)
|
|
151
157
|
except Exception as exc:
|
|
152
|
-
|
|
158
|
+
warn(f"read_events skipped {path}: {exc}")
|
|
153
159
|
continue
|
|
154
160
|
return out
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Shared Markdown primitives: frontmatter splitting and pipe-table headers.
|
|
2
|
+
|
|
3
|
+
Pure parsing only — no I/O, no schema knowledge. Feature modules keep their own
|
|
4
|
+
field aliases and value semantics.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Mapping, Optional, Sequence
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_FRONTMATTER_RE = re.compile(r"\A---[ \t]*\n(.*?)\n---[ \t]*\n?", re.DOTALL)
|
|
14
|
+
_SEPARATOR_CELL_RE = re.compile(r"^:?-{2,}:?$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_frontmatter(text: str) -> tuple[Mapping[str, object], str]:
|
|
18
|
+
"""Split optional frontmatter into flat metadata + body.
|
|
19
|
+
|
|
20
|
+
Parses only flat `key: value` string pairs — enough for `description:` —
|
|
21
|
+
so the hook hot path never needs pyyaml for this. Content without
|
|
22
|
+
frontmatter returns ({}, text) unchanged.
|
|
23
|
+
"""
|
|
24
|
+
if not isinstance(text, str):
|
|
25
|
+
return {}, text
|
|
26
|
+
match = _FRONTMATTER_RE.match(text)
|
|
27
|
+
if match is None:
|
|
28
|
+
return {}, text
|
|
29
|
+
meta: dict[str, object] = {}
|
|
30
|
+
for line in match.group(1).splitlines():
|
|
31
|
+
key, sep, value = line.partition(":")
|
|
32
|
+
if not sep:
|
|
33
|
+
continue
|
|
34
|
+
key = key.strip()
|
|
35
|
+
if key:
|
|
36
|
+
meta[key] = value.strip().strip('"').strip("'")
|
|
37
|
+
return meta, text[match.end():]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def frontmatter_block(text: str) -> Optional[str]:
|
|
41
|
+
"""Return the raw frontmatter text (no delimiters), or None when absent."""
|
|
42
|
+
if not isinstance(text, str):
|
|
43
|
+
return None
|
|
44
|
+
match = _FRONTMATTER_RE.match(text)
|
|
45
|
+
return match.group(1) if match else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def table_cells(line: str) -> tuple[str, ...]:
|
|
49
|
+
"""Split one pipe-table line into trimmed cells; () for non-table lines."""
|
|
50
|
+
stripped = line.strip()
|
|
51
|
+
if not stripped.startswith("|"):
|
|
52
|
+
return ()
|
|
53
|
+
return tuple(cell.strip() for cell in stripped.strip("|").split("|"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_separator_row(cells: Sequence[str]) -> bool:
|
|
57
|
+
"""True for the `|---|:--:|` alignment row."""
|
|
58
|
+
return bool(cells) and all(_SEPARATOR_CELL_RE.match(cell.replace(" ", "")) for cell in cells)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def table_columns(lines: Sequence[str]) -> Mapping[str, int]:
|
|
62
|
+
"""Map header names to column offsets; {} when no header row exists."""
|
|
63
|
+
for line in lines:
|
|
64
|
+
cells = table_cells(line)
|
|
65
|
+
if not cells or is_separator_row(cells):
|
|
66
|
+
continue
|
|
67
|
+
return {cell: index for index, cell in enumerate(cells)}
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def column_index(header: Sequence[str], *names: str) -> int:
|
|
72
|
+
"""Lowest header index containing any alias (case-insensitive), else -1."""
|
|
73
|
+
for index, cell in enumerate(header):
|
|
74
|
+
lowered = cell.lower()
|
|
75
|
+
if any(name.lower() in lowered for name in names):
|
|
76
|
+
return index
|
|
77
|
+
return -1
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Native runtime project discovery and all-or-nothing path validation."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from codeflow.core.cf_patch import FileOperation
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def project_root(cwd: str) -> str:
|
|
8
|
+
directory = Path(cwd).absolute()
|
|
9
|
+
for candidate in (directory, *directory.parents):
|
|
10
|
+
if (candidate / ".code-flow/config.yml").is_file():
|
|
11
|
+
return str(candidate)
|
|
12
|
+
return ""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validated_paths(root: str, cwd: str, operations: tuple[FileOperation, ...]) -> tuple[tuple[str, bool], ...]:
|
|
16
|
+
paths: dict[str, bool] = {}
|
|
17
|
+
for operation in operations:
|
|
18
|
+
for path, deleted in ((operation.path, operation.kind in {"delete", "move"}),
|
|
19
|
+
(operation.destination, False)):
|
|
20
|
+
if not path:
|
|
21
|
+
continue
|
|
22
|
+
absolute = Path(os.path.abspath(os.path.join(cwd, path)))
|
|
23
|
+
if not absolute.is_relative_to(Path(root)) or not absolute.resolve().is_relative_to(Path(root).resolve()):
|
|
24
|
+
raise ValueError(f"patch path is outside project: {path}")
|
|
25
|
+
paths[str(absolute)] = deleted
|
|
26
|
+
return tuple(paths.items())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Shared structured patch parser for native platform entries."""
|
|
2
|
+
from dataclasses import dataclass, replace
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class FileOperation:
|
|
7
|
+
kind: str
|
|
8
|
+
path: str
|
|
9
|
+
destination: str = ""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_patch(command: str) -> tuple[FileOperation, ...]:
|
|
13
|
+
lines = command.splitlines()
|
|
14
|
+
if len(lines) < 3 or lines[0] != "*** Begin Patch" or lines[-1] != "*** End Patch":
|
|
15
|
+
raise ValueError("patch must contain a complete Begin/End envelope")
|
|
16
|
+
operations: list[FileOperation] = []
|
|
17
|
+
headers = {"*** Add File: ": "add", "*** Update File: ": "update", "*** Delete File: ": "delete"}
|
|
18
|
+
for line in lines[1:-1]:
|
|
19
|
+
header = next((prefix for prefix in headers if line.startswith(prefix)), "")
|
|
20
|
+
if header:
|
|
21
|
+
path = line[len(header):]
|
|
22
|
+
if not path or "\x00" in path:
|
|
23
|
+
raise ValueError("patch file path is empty or invalid")
|
|
24
|
+
operations.append(FileOperation(headers[header], path))
|
|
25
|
+
elif line.startswith("*** Move to: "):
|
|
26
|
+
if not operations or operations[-1].kind != "update" or not line[13:]:
|
|
27
|
+
raise ValueError("Move to must follow an Update File")
|
|
28
|
+
operations[-1] = replace(operations[-1], kind="move", destination=line[13:])
|
|
29
|
+
elif not operations or (line and not line.startswith(("+", "-", " ", "@@", "*** End of File"))):
|
|
30
|
+
raise ValueError("unrecognized patch syntax")
|
|
31
|
+
if not operations:
|
|
32
|
+
raise ValueError("patch contains no file operations")
|
|
33
|
+
return tuple(operations)
|