@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
|
@@ -14,14 +14,17 @@ from typing import IO, Mapping, Optional, Sequence
|
|
|
14
14
|
|
|
15
15
|
import yaml
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
if __package__ in (None, ""):
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
19
|
+
|
|
20
|
+
from codeflow.cli.cf_spec_context import BindingInput, bind_specs, new_context, save_context
|
|
21
|
+
from codeflow.spec.cf_spec_metadata import SpecMetadata, SpecMetadataError, load_spec_metadata
|
|
22
|
+
from codeflow.spec.cf_spec_resolver import SCOPE_PRIORITY, SpecCandidate
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
TARGET_VERSION = "0.6.0"
|
|
23
26
|
TARGET_SCHEMA = 1
|
|
24
|
-
PACKAGE_ROOT = Path(__file__).resolve().parents[
|
|
27
|
+
PACKAGE_ROOT = Path(__file__).resolve().parents[6]
|
|
25
28
|
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
26
29
|
_SUFFIXES = (".frontend.design.md", ".backend.design.md", ".design.md", ".prd.md", ".md")
|
|
27
30
|
|
|
@@ -425,17 +428,31 @@ def _write_target_config(staging: Path) -> None:
|
|
|
425
428
|
path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
426
429
|
|
|
427
430
|
|
|
431
|
+
def _runtime_gitignore() -> tuple[str, list[str], str]:
|
|
432
|
+
path = Path(__file__).resolve().parents[2] / "runtime-resources.json"
|
|
433
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
434
|
+
block = data["gitignore"]
|
|
435
|
+
return block["start"], list(block["entries"]), block["end"]
|
|
436
|
+
|
|
437
|
+
|
|
428
438
|
def _write_target_ignore(staging: Path) -> None:
|
|
429
439
|
path = staging / ".code-flow/.gitignore"
|
|
430
440
|
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
)
|
|
441
|
+
# The packaged runtime-resources.json declaration is the single source shared
|
|
442
|
+
# with the CLI: migrate must not leave sessions/worktrees/backups/logs
|
|
443
|
+
# committable after an upgrade.
|
|
444
|
+
start, entries, end = _runtime_gitignore()
|
|
445
|
+
block = "\n".join([start, *entries, end]) + "\n"
|
|
436
446
|
lines = [line for line in existing.splitlines() if line != ".inject-state"]
|
|
437
|
-
if
|
|
447
|
+
if start not in existing:
|
|
438
448
|
path.write_text("\n".join(lines).rstrip() + "\n" + block, encoding="utf-8")
|
|
449
|
+
return
|
|
450
|
+
if end in lines:
|
|
451
|
+
missing = [entry for entry in entries if entry not in lines]
|
|
452
|
+
if missing:
|
|
453
|
+
at = lines.index(end)
|
|
454
|
+
lines[at:at] = missing
|
|
455
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
439
456
|
|
|
440
457
|
|
|
441
458
|
def _target_manifest(staging: Path) -> list[dict[str, str]]:
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Read-only, input-bound Spec optimization proposals."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import difflib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
from codeflow.core.cf_core import estimate_tokens, load_config
|
|
12
|
+
from codeflow.spec.cf_spec_budget import measure_path_budget
|
|
13
|
+
from codeflow.spec.cf_spec_impact import scan_impacts
|
|
14
|
+
from codeflow.spec.cf_spec_metadata import parse_spec_metadata
|
|
15
|
+
from codeflow.spec.cf_spec_resolver import resolve_candidates
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _selected(root: str, paths: Sequence[str]) -> list:
|
|
19
|
+
all_candidates = list(resolve_candidates(root, 'code', ()))
|
|
20
|
+
if not paths:
|
|
21
|
+
return all_candidates
|
|
22
|
+
normalized = [item.replace('\\', '/').removeprefix('.code-flow/specs/') for item in paths]
|
|
23
|
+
direct = {item.path for item in all_candidates if item.path in normalized}
|
|
24
|
+
code_paths = [path for path in normalized if path not in direct]
|
|
25
|
+
matched = {item.path for item in resolve_candidates(root, 'code', tuple(code_paths), matched_only=True)} if code_paths else set()
|
|
26
|
+
selected = [item for item in all_candidates if item.path in direct | matched]
|
|
27
|
+
if not selected:
|
|
28
|
+
raise ValueError('no matching specs for requested paths')
|
|
29
|
+
return selected
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _section_tokens(text: str, heading: str) -> int:
|
|
33
|
+
match = re.search(rf'(?im)^##\s+{re.escape(heading)}\s*$([\s\S]*?)(?=^##\s|\Z)', text)
|
|
34
|
+
return estimate_tokens(match.group(1)) if match else 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _spec_data(root: Path, candidate: object, config: dict) -> dict[str, object]:
|
|
38
|
+
relative = str(candidate.path)
|
|
39
|
+
base = (root / '.code-flow/specs').resolve()
|
|
40
|
+
path = (base / relative).resolve()
|
|
41
|
+
if base not in path.parents or not path.is_file():
|
|
42
|
+
raise ValueError(f'invalid spec path: {relative}')
|
|
43
|
+
raw = path.read_bytes()
|
|
44
|
+
text = raw.decode('utf-8')
|
|
45
|
+
domain = relative.split('/', 1)[0]
|
|
46
|
+
mapping = config.get('path_mapping') or {}
|
|
47
|
+
entry = mapping.get(domain, {}) if isinstance(mapping, dict) else {}
|
|
48
|
+
patterns = entry.get('patterns', []) if isinstance(entry, dict) else []
|
|
49
|
+
rules = [rule for rule in candidate.metadata.rules if rule.ref.startswith('RULE-')]
|
|
50
|
+
tokens = estimate_tokens(text)
|
|
51
|
+
rule_tokens = _section_tokens(text, 'Rules')
|
|
52
|
+
convention_tokens = _section_tokens(text, 'Conventions')
|
|
53
|
+
return {'id': candidate.spec_id, 'path': relative, 'sha256': hashlib.sha256(raw).hexdigest(),
|
|
54
|
+
'tokens': tokens, 'rule_tokens': rule_tokens, 'convention_tokens': convention_tokens,
|
|
55
|
+
'rule_percent': round(100 * rule_tokens / tokens, 2) if tokens else 0,
|
|
56
|
+
'convention_percent': round(100 * convention_tokens / tokens, 2) if tokens else 0,
|
|
57
|
+
'rule_ids': sorted(rule.ref for rule in rules), 'patterns': patterns,
|
|
58
|
+
'verifiers': [{'rule': item.rule, 'type': item.type, 'stage': item.stage, 'config': item.config}
|
|
59
|
+
for item in candidate.metadata.verifiers]}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _suggestions(specs: list[dict[str, object]], limit: int) -> list[dict[str, object]]:
|
|
63
|
+
return [{'path': item['path'], 'kind': 'review_conventions_split',
|
|
64
|
+
'reason': '规范超过单次注入上限;需人工选择目标,规则保持原位'}
|
|
65
|
+
for item in specs if int(item['tokens']) > limit and int(item['convention_tokens']) > 0]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _overlaps(specs: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
69
|
+
groups: list[dict[str, object]] = []
|
|
70
|
+
for index, first in enumerate(specs):
|
|
71
|
+
for second in specs[index + 1:]:
|
|
72
|
+
shared = sorted(set(first['patterns']) & set(second['patterns']))
|
|
73
|
+
if shared:
|
|
74
|
+
groups.append({'paths': [first['path'], second['path']], 'patterns': shared})
|
|
75
|
+
return groups
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _target_path(root: Path, target: str) -> Path:
|
|
79
|
+
relative = Path(target)
|
|
80
|
+
if relative.is_absolute() or '..' in relative.parts or len(relative.parts) < 2 or relative.suffix != '.md':
|
|
81
|
+
raise ValueError('target must be a relative spec path inside an existing domain')
|
|
82
|
+
if relative.parts[0] in ('shared', '_session') or relative.parts[0].startswith('.'):
|
|
83
|
+
raise ValueError('target domain must be an injectable spec domain')
|
|
84
|
+
base = (root / '.code-flow/specs').resolve()
|
|
85
|
+
path = base / relative
|
|
86
|
+
if any((base.joinpath(*relative.parts[:i])).is_symlink() for i in range(1, len(relative.parts) + 1)):
|
|
87
|
+
raise ValueError('target path must not contain symlinks')
|
|
88
|
+
if base not in path.resolve().parents or not path.parent.is_dir():
|
|
89
|
+
raise ValueError('target domain directory must exist within specs')
|
|
90
|
+
if path.exists() or path.is_symlink():
|
|
91
|
+
raise ValueError('target already exists')
|
|
92
|
+
return path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _movement(root: Path, specs: list[dict[str, object]], target: str, config: dict) -> tuple[list[dict], list[dict]]:
|
|
96
|
+
if len(specs) != 1:
|
|
97
|
+
raise ValueError('target requires exactly one source spec')
|
|
98
|
+
destination = _target_path(root, target)
|
|
99
|
+
source = specs[0]
|
|
100
|
+
base = root / '.code-flow/specs'
|
|
101
|
+
relative_source = Path(str(source['path']))
|
|
102
|
+
if any(base.joinpath(*relative_source.parts[:i]).is_symlink() for i in range(1, len(relative_source.parts) + 1)):
|
|
103
|
+
raise ValueError('source path must not contain symlinks')
|
|
104
|
+
text = (base / relative_source).read_text(encoding='utf-8')
|
|
105
|
+
match = re.search(r'(?im)^##\s+Conventions\s*$([\s\S]*?)(?=^##\s|\Z)', text)
|
|
106
|
+
if not match or 'RULE-' in match.group(0):
|
|
107
|
+
raise ValueError('source conventions missing or contain rule references')
|
|
108
|
+
new_id = str(source['id']) + '-conventions'
|
|
109
|
+
for stage in ('prd', 'design', 'plan', 'code', 'review'):
|
|
110
|
+
if any(item.spec_id == new_id for item in resolve_candidates(str(root), stage, ())):
|
|
111
|
+
raise ValueError('target spec id already exists')
|
|
112
|
+
relative = destination.relative_to(root / '.code-flow/specs').as_posix()
|
|
113
|
+
operation = {'kind': 'move_conventions', 'source': source['path'], 'target': relative, 'target_id': new_id}
|
|
114
|
+
source_after, target_after = render_movement(root, operation)
|
|
115
|
+
operation.update({'source_after_sha256': hashlib.sha256(source_after).hexdigest(),
|
|
116
|
+
'target_after_sha256': hashlib.sha256(target_after).hexdigest(),
|
|
117
|
+
'source_after_tokens': estimate_tokens(source_after.decode('utf-8')),
|
|
118
|
+
'target_after_tokens': estimate_tokens(target_after.decode('utf-8')),
|
|
119
|
+
'source_diff': ''.join(difflib.unified_diff(text.splitlines(keepends=True),
|
|
120
|
+
source_after.decode('utf-8').splitlines(keepends=True), fromfile=str(source['path']),
|
|
121
|
+
tofile=str(source['path']))),
|
|
122
|
+
'target_diff': ''.join(difflib.unified_diff([], target_after.decode('utf-8').splitlines(keepends=True),
|
|
123
|
+
fromfile='/dev/null', tofile=relative))})
|
|
124
|
+
domain = relative.split('/')[0]
|
|
125
|
+
mapping = config.get('path_mapping') or {}
|
|
126
|
+
entry = mapping.get(domain, {}) if isinstance(mapping, dict) else {}
|
|
127
|
+
patterns = entry.get('patterns', []) if isinstance(entry, dict) else []
|
|
128
|
+
return [operation], [{'kind': 'auto_discovery_add', 'domain': domain, 'path': relative,
|
|
129
|
+
'source_patterns': source['patterns'], 'target_patterns': patterns}]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def render_movement(root: Path, operation: dict) -> tuple[bytes, bytes]:
|
|
133
|
+
source = root / '.code-flow/specs' / str(operation['source'])
|
|
134
|
+
text = source.read_text(encoding='utf-8')
|
|
135
|
+
match = re.search(r'(?im)^##\s+Conventions\s*$([\s\S]*?)(?=^##\s|\Z)', text)
|
|
136
|
+
if not match or 'RULE-' in match.group(0):
|
|
137
|
+
raise ValueError('rule guard: invalid conventions section')
|
|
138
|
+
updated = text[:match.start()] + text[match.end():]
|
|
139
|
+
before = parse_spec_metadata(text, path=str(source))
|
|
140
|
+
target = ('---\nid: ' + str(operation['target_id']) + '\ndescription: Migrated conventions\n'
|
|
141
|
+
'stages: ' + json.dumps(list(before.stages)) + '\nenforcement: advisory\n---\n\n'
|
|
142
|
+
'# Conventions\n\n' + match.group(0) + '\n')
|
|
143
|
+
after = parse_spec_metadata(updated, path=str(source))
|
|
144
|
+
target_meta = parse_spec_metadata(target, path=str(root / '.code-flow/specs' / str(operation['target'])))
|
|
145
|
+
signature = lambda meta: [(rule.ref, rule.text_sha256, rule.section, rule.enforcement) for rule in meta.rules]
|
|
146
|
+
if signature(before) != signature(after) or before.verifiers != after.verifiers or target_meta.verifiers:
|
|
147
|
+
raise ValueError('rule guard: rule or verifier changed')
|
|
148
|
+
if any(item.ref.startswith('RULE-') for item in target_meta.rules):
|
|
149
|
+
raise ValueError('rule guard: target contains rules')
|
|
150
|
+
return updated.encode('utf-8'), target.encode('utf-8')
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def build_proposal(root: str, paths: Sequence[str], target: str = '') -> dict[str, object]:
|
|
154
|
+
project = Path(root).resolve()
|
|
155
|
+
config = load_config(root)
|
|
156
|
+
candidates = _selected(root, paths)
|
|
157
|
+
specs = [_spec_data(project, candidate, config) for candidate in candidates]
|
|
158
|
+
operations, mapping_diff = _movement(project, specs, target, config) if target else ([], [])
|
|
159
|
+
identities = {str(item['id']): str(item['path']) for item in specs}
|
|
160
|
+
for operation in operations:
|
|
161
|
+
identities[str(operation['target_id'])] = str(operation['target'])
|
|
162
|
+
impact = scan_impacts(root, identities)
|
|
163
|
+
if impact['truncated'] or impact['errors']:
|
|
164
|
+
raise ValueError('impact scan incomplete; proposal cannot assert complete scope')
|
|
165
|
+
budget = config.get('budget') or {}
|
|
166
|
+
if not isinstance(budget, dict):
|
|
167
|
+
raise ValueError('budget must be a mapping')
|
|
168
|
+
limit = min(int(budget.get('total', 2500)), int(budget.get('l1_max', 1700)))
|
|
169
|
+
if limit <= 0:
|
|
170
|
+
raise ValueError('budget limit must be positive')
|
|
171
|
+
config_hash = hashlib.sha256((project / '.code-flow/config.yml').read_bytes()).hexdigest()
|
|
172
|
+
code_paths = [path for path in paths if not any(path in (item['path'], '.code-flow/specs/' + str(item['path'])) for item in specs)]
|
|
173
|
+
measured = measure_path_budget(root, code_paths) if code_paths else None
|
|
174
|
+
raw_before = sum(int(item['tokens']) for item in specs)
|
|
175
|
+
raw_after = (raw_before - int(specs[0]['tokens']) + int(operations[0]['source_after_tokens'])
|
|
176
|
+
+ int(operations[0]['target_after_tokens'])) if operations else raw_before
|
|
177
|
+
affected_files = {str(row.get('context_path') or row.get('task_path')) for row in impact['references']}
|
|
178
|
+
affected_hashes = {path: hashlib.sha256((project / path).read_bytes()).hexdigest() for path in sorted(affected_files)}
|
|
179
|
+
overlap = _overlaps(specs)
|
|
180
|
+
suggestions = _suggestions(specs, limit)
|
|
181
|
+
suggestions.extend({'paths': group['paths'], 'kind': 'review_overlap',
|
|
182
|
+
'reason': '相同路径模式命中多份规范;人工评估是否合并 Conventions'} for group in overlap)
|
|
183
|
+
proposal = {'schema': 1, 'config_sha256': config_hash, 'specs': specs,
|
|
184
|
+
'budget': {'before': {'raw_spec_tokens': raw_before, 'path_measurement': measured},
|
|
185
|
+
'after': {'raw_spec_tokens': raw_after, 'path_measurement': None if operations else measured},
|
|
186
|
+
'limit': limit, 'note': '变更后单次注入需在应用后重新测量'},
|
|
187
|
+
'mapping_diff': mapping_diff, 'affected_contexts': impact['references'],
|
|
188
|
+
'affected_file_hashes': affected_hashes,
|
|
189
|
+
'scanned_files': impact['scanned_files'], 'path_overlaps': overlap, 'suggestions': suggestions,
|
|
190
|
+
'operations': operations, 'note': ('请审核提案并交互确认应用。' if target
|
|
191
|
+
else '未指定迁移目标,提案没有可应用的文件变更。')}
|
|
192
|
+
proposal['proposal_id'] = hashlib.sha256(json.dumps(proposal, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
|
|
193
|
+
return proposal
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Guarded, recoverable application of a reviewed conventions proposal."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shutil
|
|
9
|
+
from typing import Mapping
|
|
10
|
+
|
|
11
|
+
from codeflow.spec.cf_spec_optimize import build_proposal, render_movement
|
|
12
|
+
from codeflow.task.cf_task_doc import TaskDocumentError, parse_task_document
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _digest(data: bytes) -> str:
|
|
16
|
+
return hashlib.sha256(data).hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _proposal_hash(proposal: Mapping[str, object]) -> str:
|
|
20
|
+
unsigned = {key: value for key, value in proposal.items() if key != 'proposal_id'}
|
|
21
|
+
return _digest(json.dumps(unsigned, ensure_ascii=False, sort_keys=True).encode())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _check_context_tasks(root: Path, directory: str) -> None:
|
|
25
|
+
task_directory = root / directory
|
|
26
|
+
statuses: list[str] = []
|
|
27
|
+
for path in sorted(task_directory.glob('*.md')):
|
|
28
|
+
if path.name.endswith(('.prd.md', '.design.md', '.scenarios.md')):
|
|
29
|
+
continue
|
|
30
|
+
try:
|
|
31
|
+
document = parse_task_document(path.read_text(encoding='utf-8'), source=str(path))
|
|
32
|
+
except TaskDocumentError as exc:
|
|
33
|
+
if exc.code == 'task_document_empty':
|
|
34
|
+
continue
|
|
35
|
+
raise
|
|
36
|
+
statuses.extend(task.status for task in document.tasks)
|
|
37
|
+
if not statuses or any(status not in ('done', 'verified') for status in statuses):
|
|
38
|
+
raise ValueError('inflight_context: affected Context has incomplete or unproven tasks')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _check_inflight(root: str, proposal: Mapping[str, object]) -> None:
|
|
42
|
+
refs = proposal.get('affected_contexts')
|
|
43
|
+
if not isinstance(refs, list):
|
|
44
|
+
raise ValueError('proposal impact evidence missing')
|
|
45
|
+
tasks = [row for row in refs if isinstance(row, dict) and row.get('source') == 'task' and not row.get('archived')]
|
|
46
|
+
contexts = [row for row in refs if isinstance(row, dict) and row.get('source') == 'context' and not row.get('archived')]
|
|
47
|
+
if any(row.get('status') not in ('done', 'verified') for row in tasks):
|
|
48
|
+
raise ValueError('inflight_context: affected task is not complete')
|
|
49
|
+
directories = {str(Path(str(row.get('context_path', ''))).parent) for row in contexts}
|
|
50
|
+
for directory in sorted(directories):
|
|
51
|
+
_check_context_tasks(Path(root), directory)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate(root: str, proposal: Mapping[str, object]) -> dict[str, object]:
|
|
55
|
+
if proposal.get('schema') != 1 or proposal.get('proposal_id') != _proposal_hash(proposal):
|
|
56
|
+
raise ValueError('proposal guard: invalid proposal identity')
|
|
57
|
+
operations = proposal.get('operations')
|
|
58
|
+
if not isinstance(operations, list) or len(operations) != 1 or not isinstance(operations[0], dict):
|
|
59
|
+
raise ValueError('proposal guard: exactly one explicit operation is required')
|
|
60
|
+
operation = operations[0]
|
|
61
|
+
if operation.get('kind') != 'move_conventions' or not isinstance(operation.get('source'), str) or not isinstance(operation.get('target'), str):
|
|
62
|
+
raise ValueError('proposal guard: invalid operation')
|
|
63
|
+
try:
|
|
64
|
+
current = build_proposal(root, (operation['source'],), str(operation['target']))
|
|
65
|
+
except (OSError, ValueError) as exc:
|
|
66
|
+
raise ValueError(f'proposal_stale: {exc}') from exc
|
|
67
|
+
if current != proposal:
|
|
68
|
+
raise ValueError('proposal_stale: input, impact, or operation changed')
|
|
69
|
+
_check_inflight(root, proposal)
|
|
70
|
+
return operation
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _write(path: Path, data: bytes, mode: int = 0o600) -> None:
|
|
74
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
|
|
75
|
+
with os.fdopen(descriptor, 'wb') as handle:
|
|
76
|
+
os.fchmod(handle.fileno(), mode)
|
|
77
|
+
handle.write(data)
|
|
78
|
+
handle.flush()
|
|
79
|
+
os.fsync(handle.fileno())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _journal(directory: Path, data: dict[str, object]) -> None:
|
|
83
|
+
temporary = directory / 'journal.tmp'
|
|
84
|
+
if temporary.exists():
|
|
85
|
+
raise ValueError('transaction journal temporary file already exists')
|
|
86
|
+
_write(temporary, (json.dumps(data, ensure_ascii=False, sort_keys=True) + '\n').encode())
|
|
87
|
+
os.replace(temporary, directory / 'journal.json')
|
|
88
|
+
descriptor = os.open(directory, os.O_RDONLY)
|
|
89
|
+
try:
|
|
90
|
+
os.fsync(descriptor)
|
|
91
|
+
finally:
|
|
92
|
+
os.close(descriptor)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def commit_replace(staged: Path, destination: Path) -> None:
|
|
96
|
+
"""Replace the source; create a new target exclusively without overwriting."""
|
|
97
|
+
if staged.name == 'target.staged':
|
|
98
|
+
os.link(staged, destination)
|
|
99
|
+
else:
|
|
100
|
+
os.replace(staged, destination)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _rollback(source: Path, target: Path, backup: Path, original_hash: str,
|
|
104
|
+
source_after_hash: str, target_after_hash: str, target_created: bool) -> None:
|
|
105
|
+
if target_created:
|
|
106
|
+
if not target.is_file() or _digest(target.read_bytes()) != target_after_hash:
|
|
107
|
+
raise ValueError('recovery_required: target changed during rollback')
|
|
108
|
+
target.unlink()
|
|
109
|
+
current = _digest(source.read_bytes()) if source.is_file() else ''
|
|
110
|
+
if current == original_hash:
|
|
111
|
+
return
|
|
112
|
+
if current != source_after_hash or _digest(backup.read_bytes()) != original_hash:
|
|
113
|
+
raise ValueError('recovery_required: source changed during rollback')
|
|
114
|
+
os.replace(backup, source)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _remove_transaction(directory: Path, parent_existed: bool) -> None:
|
|
118
|
+
parent = directory.parent
|
|
119
|
+
shutil.rmtree(directory)
|
|
120
|
+
if not parent_existed:
|
|
121
|
+
parent.rmdir()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _transaction(root: Path, proposal: Mapping[str, object], operation: Mapping[str, object]) -> dict[str, object]:
|
|
125
|
+
base = root / '.code-flow/specs'
|
|
126
|
+
source = base / str(operation['source'])
|
|
127
|
+
target = base / str(operation['target'])
|
|
128
|
+
original = source.read_bytes()
|
|
129
|
+
after_source, after_target = render_movement(root, dict(operation))
|
|
130
|
+
specs = proposal.get('specs')
|
|
131
|
+
if (not isinstance(specs, list) or len(specs) != 1 or not isinstance(specs[0], dict)
|
|
132
|
+
or _digest(original) != specs[0].get('sha256')
|
|
133
|
+
or _digest(after_source) != operation.get('source_after_sha256')
|
|
134
|
+
or _digest(after_target) != operation.get('target_after_sha256')):
|
|
135
|
+
raise ValueError('proposal_stale: source or proposed result changed')
|
|
136
|
+
parent = base / '.optimize-transactions'
|
|
137
|
+
if parent.is_symlink() or (parent.exists() and (not parent.is_dir() or any(parent.iterdir()))):
|
|
138
|
+
raise ValueError('recovery_required: unfinished optimization transaction')
|
|
139
|
+
parent_existed = parent.exists()
|
|
140
|
+
parent.mkdir(exist_ok=True)
|
|
141
|
+
directory = parent / str(proposal['proposal_id'])
|
|
142
|
+
directory.mkdir()
|
|
143
|
+
backup, source_stage, target_stage = directory / 'source.backup', directory / 'source.staged', directory / 'target.staged'
|
|
144
|
+
target_created = False
|
|
145
|
+
state = {'schema': 1, 'proposal_id': proposal['proposal_id'], 'source': operation['source'],
|
|
146
|
+
'target': operation['target'], 'original_sha256': _digest(original),
|
|
147
|
+
'source_after_sha256': _digest(after_source), 'target_after_sha256': _digest(after_target), 'state': 'prepared'}
|
|
148
|
+
try:
|
|
149
|
+
_write(backup, original, source.stat().st_mode & 0o777)
|
|
150
|
+
_write(source_stage, after_source, source.stat().st_mode & 0o777)
|
|
151
|
+
_write(target_stage, after_target, 0o644)
|
|
152
|
+
_journal(directory, state)
|
|
153
|
+
state['state'] = 'committing'
|
|
154
|
+
_journal(directory, state)
|
|
155
|
+
if _digest(source.read_bytes()) != state['original_sha256']:
|
|
156
|
+
raise ValueError('proposal_stale: source changed before commit')
|
|
157
|
+
commit_replace(source_stage, source)
|
|
158
|
+
commit_replace(target_stage, target)
|
|
159
|
+
target_created = True
|
|
160
|
+
if _digest(source.read_bytes()) != state['source_after_sha256'] or _digest(target.read_bytes()) != state['target_after_sha256']:
|
|
161
|
+
raise ValueError('post-write guard: content differs from approved proposal')
|
|
162
|
+
state['state'] = 'committed'
|
|
163
|
+
_journal(directory, state)
|
|
164
|
+
except (OSError, ValueError):
|
|
165
|
+
_rollback(source, target, backup, _digest(original), _digest(after_source), _digest(after_target), target_created)
|
|
166
|
+
_remove_transaction(directory, parent_existed)
|
|
167
|
+
raise
|
|
168
|
+
_remove_transaction(directory, parent_existed)
|
|
169
|
+
return {'decision': 'pass', 'proposal_id': proposal['proposal_id'],
|
|
170
|
+
'changed': [str(operation['source']), str(operation['target'])]}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def apply_proposal(root: str, proposal: Mapping[str, object], *, confirmed: bool) -> dict[str, object]:
|
|
174
|
+
if not confirmed:
|
|
175
|
+
raise ValueError('explicit confirmation required')
|
|
176
|
+
project = Path(root).resolve()
|
|
177
|
+
lock = project / '.code-flow/specs/.optimize.lock'
|
|
178
|
+
try:
|
|
179
|
+
descriptor = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
180
|
+
except FileExistsError as exc:
|
|
181
|
+
raise ValueError('recovery_required: optimization lock exists') from exc
|
|
182
|
+
os.close(descriptor)
|
|
183
|
+
try:
|
|
184
|
+
operation = _validate(root, proposal)
|
|
185
|
+
return _transaction(project, proposal, operation)
|
|
186
|
+
finally:
|
|
187
|
+
lock.unlink()
|
package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py}
RENAMED
|
@@ -8,7 +8,7 @@ import fnmatch
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Mapping, Optional, Sequence
|
|
10
10
|
|
|
11
|
-
from cf_spec_metadata import (
|
|
11
|
+
from codeflow.spec.cf_spec_metadata import (
|
|
12
12
|
ALLOWED_STAGES,
|
|
13
13
|
SpecHeader,
|
|
14
14
|
SpecMetadata,
|
|
@@ -19,8 +19,8 @@ from cf_spec_metadata import (
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
SCOPE_PRIORITY = {"unmatched": 0, "global": 1, "path": 2, "task": 3}
|
|
22
|
-
_config_cache: dict[str, tuple[int, int, Mapping[str, object]]] = {}
|
|
23
|
-
_metadata_cache: dict[str, tuple[int, int, SpecMetadata]] = {}
|
|
22
|
+
_config_cache: dict[str, tuple[int, int, int, Mapping[str, object]]] = {}
|
|
23
|
+
_metadata_cache: dict[str, tuple[int, int, int, SpecMetadata]] = {}
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
class SpecResolutionError(ValueError):
|
|
@@ -99,12 +99,12 @@ class _RuleSource:
|
|
|
99
99
|
enforcement: str
|
|
100
100
|
|
|
101
101
|
|
|
102
|
-
def _fingerprint(path: Path, field: str) -> tuple[int, int]:
|
|
102
|
+
def _fingerprint(path: Path, field: str) -> tuple[int, int, int]:
|
|
103
103
|
try:
|
|
104
104
|
stat = path.stat()
|
|
105
105
|
except OSError as exc:
|
|
106
106
|
raise SpecResolutionError(str(path), field, f"读取状态失败: {exc}") from exc
|
|
107
|
-
return stat.st_mtime_ns, stat.st_size
|
|
107
|
+
return stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size
|
|
108
108
|
|
|
109
109
|
|
|
110
110
|
def _load_config(root: str) -> Mapping[str, object]:
|
|
@@ -112,17 +112,17 @@ def _load_config(root: str) -> Mapping[str, object]:
|
|
|
112
112
|
path = Path(root) / ".code-flow" / "config.yml"
|
|
113
113
|
if not path.is_file():
|
|
114
114
|
raise SpecResolutionError(str(path), "config", "缺失 config.yml")
|
|
115
|
-
|
|
115
|
+
signature = _fingerprint(path, "config")
|
|
116
116
|
cached = _config_cache.get(str(path))
|
|
117
|
-
if cached and cached[:
|
|
118
|
-
return cached[
|
|
117
|
+
if cached and cached[:3] == signature:
|
|
118
|
+
return cached[3]
|
|
119
119
|
try:
|
|
120
120
|
loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
121
121
|
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
|
122
122
|
raise SpecResolutionError(str(path), "config", f"解析失败: {exc}") from exc
|
|
123
123
|
if not isinstance(loaded, dict) or not all(isinstance(key, str) for key in loaded):
|
|
124
124
|
raise SpecResolutionError(str(path), "config", "顶层必须是字符串 key mapping")
|
|
125
|
-
_config_cache[str(path)] = (
|
|
125
|
+
_config_cache[str(path)] = (*signature, loaded)
|
|
126
126
|
return loaded
|
|
127
127
|
|
|
128
128
|
|
|
@@ -161,12 +161,12 @@ def _non_injectable(mapping: Mapping[str, object], config_path: str) -> set[str]
|
|
|
161
161
|
|
|
162
162
|
|
|
163
163
|
def _load_metadata(path: Path) -> SpecMetadata:
|
|
164
|
-
|
|
164
|
+
signature = _fingerprint(path, "spec")
|
|
165
165
|
cached = _metadata_cache.get(str(path))
|
|
166
|
-
if cached and cached[:
|
|
167
|
-
return cached[
|
|
166
|
+
if cached and cached[:3] == signature:
|
|
167
|
+
return cached[3]
|
|
168
168
|
metadata = load_spec_metadata(str(path))
|
|
169
|
-
_metadata_cache[str(path)] = (
|
|
169
|
+
_metadata_cache[str(path)] = (*signature, metadata)
|
|
170
170
|
return metadata
|
|
171
171
|
|
|
172
172
|
|
|
@@ -231,7 +231,27 @@ def _spec_paths(spec_root: Path, excluded: set[str]) -> tuple[Path, ...]:
|
|
|
231
231
|
return tuple(result)
|
|
232
232
|
|
|
233
233
|
|
|
234
|
-
def
|
|
234
|
+
def _routing_paths(
|
|
235
|
+
spec_root: Path, excluded: set[str], mapping: Mapping[str, object],
|
|
236
|
+
config_path: str, paths: Sequence[str], matched_only: bool,
|
|
237
|
+
) -> tuple[Path, ...]:
|
|
238
|
+
discovered = _spec_paths(spec_root, excluded)
|
|
239
|
+
if not matched_only:
|
|
240
|
+
return discovered
|
|
241
|
+
relevant = []
|
|
242
|
+
for path in discovered:
|
|
243
|
+
relative = path.relative_to(spec_root).as_posix()
|
|
244
|
+
domain = relative.split("/", 1)[0]
|
|
245
|
+
patterns = _patterns(_domain_config(mapping, domain, config_path),
|
|
246
|
+
f"path_mapping.{domain}.patterns", config_path)
|
|
247
|
+
if _candidate_scope(relative, patterns, paths)[0] != "unmatched":
|
|
248
|
+
relevant.append(path)
|
|
249
|
+
return tuple(relevant)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def resolve_candidates(
|
|
253
|
+
root: str, stage: str, paths: Sequence[str], *, matched_only: bool = False,
|
|
254
|
+
) -> tuple[SpecCandidate, ...]:
|
|
235
255
|
if stage not in ALLOWED_STAGES:
|
|
236
256
|
raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
|
|
237
257
|
normalized_paths = tuple(path.replace("\\", "/") for path in paths)
|
|
@@ -244,7 +264,7 @@ def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[Spe
|
|
|
244
264
|
raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
|
|
245
265
|
candidates: list[SpecCandidate] = []
|
|
246
266
|
ids: dict[str, str] = {}
|
|
247
|
-
for path in
|
|
267
|
+
for path in _routing_paths(spec_root, excluded, mapping, config_path, normalized_paths, matched_only):
|
|
248
268
|
candidate = _candidate_for(path, spec_root, mapping, config_path, stage, normalized_paths)
|
|
249
269
|
if candidate is None:
|
|
250
270
|
continue
|
|
@@ -257,7 +277,8 @@ def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[Spe
|
|
|
257
277
|
|
|
258
278
|
|
|
259
279
|
def resolve_candidate_headers(
|
|
260
|
-
root: str, stage: str, paths: Sequence[str]
|
|
280
|
+
root: str, stage: str, paths: Sequence[str], *, matched_only: bool = False,
|
|
281
|
+
excluded_paths: Sequence[str] = (),
|
|
261
282
|
) -> tuple[SpecCandidateHeader, ...]:
|
|
262
283
|
"""Resolve candidates from routing frontmatter without parsing bodies or hashes."""
|
|
263
284
|
if stage not in ALLOWED_STAGES:
|
|
@@ -267,12 +288,13 @@ def resolve_candidate_headers(
|
|
|
267
288
|
config_path = str(Path(root) / ".code-flow" / "config.yml")
|
|
268
289
|
mapping = _path_mapping(config, config_path)
|
|
269
290
|
excluded = _non_injectable(mapping, config_path)
|
|
291
|
+
excluded.update(excluded_paths)
|
|
270
292
|
spec_root = Path(root) / ".code-flow" / "specs"
|
|
271
293
|
if not spec_root.is_dir():
|
|
272
294
|
raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
|
|
273
295
|
candidates: list[SpecCandidateHeader] = []
|
|
274
296
|
ids: dict[str, str] = {}
|
|
275
|
-
for path in
|
|
297
|
+
for path in _routing_paths(spec_root, excluded, mapping, config_path, normalized_paths, matched_only):
|
|
276
298
|
candidate = _candidate_header_for(path, spec_root, mapping, config_path, stage, normalized_paths)
|
|
277
299
|
if candidate is None:
|
|
278
300
|
continue
|