diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..84a5bc9f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Deterministically generated analysis snapshots are validated by CI. +# Keep them versioned while excluding their regenerated bodies from review diffs. +SUMD.md -diff linguist-generated=true +SUMR.md -diff linguist-generated=true +docs/README.md -diff linguist-generated=true +planfile.yaml -diff linguist-generated=true +project/** -diff linguist-generated=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 97bcca25..8f207a24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,91 @@ # Changelog +## [0.5.1] - 2026-08-16 + +### Fixed +- Fix ast-unused-imports issues (ticket-4ba5fea1) +- Fix ast-sorted-imports issues (ticket-c3eb2df8) +- Fix ast-string-concat issues (ticket-8022e9d4) +- Fix ast-print-statements issues (ticket-73442493) +- Fix ruff-print-statements issues (ticket-78a74b25) +- Fix string-concat-fstring issues (ticket-5e941e99) +- Fix import-optimization issues (ticket-bfb77511) +- Fix ast-unused-imports issues (ticket-1207cf86) +- Fix ast-sorted-imports issues (ticket-5426b265) +- Fix ast-string-concat issues (ticket-cec0a95c) +- Fix ast-print-statements issues (ticket-cb8d0d20) +- Fix ruff-print-statements issues (ticket-8033862c) +- Fix string-concat-fstring issues (ticket-20d6bd0f) +- Fix ai-boilerplate issues (ticket-58a9aa28) +- Fix import-optimization issues (ticket-735c310d) +- Fix ast-unused-imports issues (ticket-56d37d70) +- Fix ast-sorted-imports issues (ticket-87efcac4) +- Fix ast-print-statements issues (ticket-bd0eb27d) +- Fix ruff-print-statements issues (ticket-f27a6d9f) +- Fix ai-boilerplate issues (ticket-90bfc403) +- Fix import-optimization issues (ticket-3230b5ff) +- Fix ast-unused-imports issues (ticket-8b4a03c1) +- Fix ast-sorted-imports issues (ticket-c31b4b42) +- Fix ast-print-statements issues (ticket-b2d212c4) +- Fix ruff-print-statements issues (ticket-e276cf83) +- Fix magic-numbers issues (ticket-4b9ed48e) +- Fix ai-boilerplate issues (ticket-db6f0e73) +- Fix import-optimization issues (ticket-58094a3d) +- Fix ast-sorted-imports issues (ticket-71e0eb0c) +- Fix ast-missing-return-type issues (ticket-732720bd) +- Fix ruff-sorted-imports issues (ticket-1b95e87a) +- Fix smart-return-type issues (ticket-2fef4e7f) +- Fix ai-boilerplate issues (ticket-e702f60d) +- Fix import-optimization issues (ticket-06011d26) +- Fix ast-unused-imports issues (ticket-099f1873) +- Fix ast-sorted-imports issues (ticket-90085a7c) +- Fix ast-string-concat issues (ticket-c5720ca3) +- Fix ast-print-statements issues (ticket-0fe32656) +- Fix ruff-print-statements issues (ticket-82d944e5) +- Fix string-concat-fstring issues (ticket-6c9c37a3) +- Fix ai-boilerplate issues (ticket-32e56943) +- Fix import-optimization issues (ticket-11fa42a5) +- Fix relative-imports issues (ticket-2ca30104) +- Fix no-relative-imports issues (ticket-adab9407) +- Fix relative-imports issues (ticket-a9a5d9ca) +- Fix import-optimization issues (ticket-adae0c4f) +- Fix no-relative-imports issues (ticket-6b6eb288) +- Fix ast-unused-imports issues (ticket-155ccd5a) +- Fix ast-sorted-imports issues (ticket-43404879) +- Fix magic-numbers issues (ticket-e1b5cb8f) +- Fix import-optimization issues (ticket-a42630d6) +- Fix ast-unused-imports issues (ticket-858b5c0d) +- Fix ast-duplicate-imports issues (ticket-debeb609) +- Fix ast-sorted-imports issues (ticket-6f5cfce3) +- Fix ast-string-concat issues (ticket-4cf163ba) +- Fix string-concat-fstring issues (ticket-5dcdcbf7) +- Fix importchecker-duplicate-imports issues (ticket-64435f7c) +- Fix magic-numbers issues (ticket-a62cce1b) +- Fix llm-generated-code issues (ticket-a21ddd72) +- Fix relative-imports issues (ticket-54646020) +- Fix ast-unused-imports issues (ticket-1c08cd50) +- Fix ruff-sorted-imports issues (ticket-be7783a8) +- Fix magic-numbers issues (ticket-65bcf7fb) +- Fix import-optimization issues (ticket-3b9f2c84) +- Fix no-relative-imports issues (ticket-ab53a2ef) +- Fix ast-unused-imports issues (ticket-136b561e) +- Fix ast-string-concat issues (ticket-adc36dba) +- Fix ast-print-statements issues (ticket-9ef611a4) +- Fix ruff-print-statements issues (ticket-d8100601) +- Fix ruff-sorted-imports issues (ticket-19744070) +- Fix string-concat-fstring issues (ticket-ff16970e) +- Fix magic-numbers issues (ticket-ecb171ec) +- Fix ai-boilerplate issues (ticket-f7463c34) +- Fix import-optimization issues (ticket-f0c3b8cd) +- Fix ast-unused-imports issues (ticket-cf4b5285) +- Fix ast-sorted-imports issues (ticket-185f57cf) +- Fix ast-string-concat issues (ticket-e54ace76) +- Fix ast-print-statements issues (ticket-07617160) +- Fix ruff-print-statements issues (ticket-0fca97a4) +- Fix string-concat-fstring issues (ticket-8e5a44bf) +- Fix ai-boilerplate issues (ticket-d8397164) +- Fix import-optimization issues (ticket-c5c015c0) + ## [Unreleased] ### Changed diff --git a/TODO.md b/TODO.md index d811838d..c4a92cd9 100644 --- a/TODO.md +++ b/TODO.md @@ -549,3 +549,140 @@ guardem intencji: LLM może proponować, runtime waliduje, człowiek zatwierdza. 3. Code-change plan ugruntowany w diagnostyce (nie free-form). 4. Po zmianie kodu re-analiza zamyka targeted diagnostics bez nowych blocking. 5. Brak auto-DONE: człowiek lub uprawniony klient zatwierdza. + + +# TODO + +**Generated by:** prefact v0.1.68 +**Generated on:** 2026-08-16T12:55:12.970071 +**Total issues:** 120 active, 0 completed + +--- + +## 📋 Current Issues + +- [ ] scripts/package.py:2 - Unused import: 'annotations' +- [ ] scripts/package.py:2 - Import block is not sorted (stdlib → third-party → local). +- [ ] scripts/package.py:22 - String concatenation could use f-string. +- [ ] scripts/package.py:24 - print() call – consider using logging instead. +- [ ] scripts/package.py:25 - print() call – consider using logging instead. +- [ ] scripts/package.py:24 - `print` found +- [ ] scripts/package.py:25 - `print` found +- [ ] scripts/package.py:22 - String concatenation can be converted to f-string +- [ ] scripts/package.py:31 - Import used only once: hashlib +- [ ] .governance/decision_record.py:9 - Unused import: 'annotations' +- [ ] .governance/decision_record.py:9 - Import block is not sorted (stdlib → third-party → local). +- [ ] .governance/decision_record.py:173 - String concatenation could use f-string. +- [ ] .governance/decision_record.py:373 - print() call – consider using logging instead. +- [ ] .governance/decision_record.py:377 - print() call – consider using logging instead. +- [ ] .governance/decision_record.py:388 - print() call – consider using logging instead. +- [ ] .governance/decision_record.py:369 - `print` found +- [ ] .governance/decision_record.py:371 - `print` found +- [ ] .governance/decision_record.py:373 - `print` found +- [ ] .governance/decision_record.py:173 - String concatenation can be converted to f-string +- [ ] .governance/decision_record.py:393 - module execution block +- [ ] .governance/decision_record.py:101 - Import used only once: argparse +- [ ] .governance/decision_record.py:111 - Import used only once: hashlib +- [ ] .governance/check_required_checks.py:15 - Unused import: 'annotations' +- [ ] .governance/check_required_checks.py:15 - Import block is not sorted (stdlib → third-party → local). +- [ ] .governance/check_required_checks.py:129 - print() call – consider using logging instead. +- [ ] .governance/check_required_checks.py:119 - print() call – consider using logging instead. +- [ ] .governance/check_required_checks.py:125 - print() call – consider using logging instead. +- [ ] .governance/check_required_checks.py:119 - `print` found +- [ ] .governance/check_required_checks.py:125 - `print` found +- [ ] .governance/check_required_checks.py:127 - `print` found +- [ ] .governance/check_required_checks.py:137 - module execution block +- [ ] .governance/check_required_checks.py:161 - Import used only once: argparse +- [ ] python/ast_extract.py:3 - Unused import: 'annotations' +- [ ] python/ast_extract.py:3 - Import block is not sorted (stdlib → third-party → local). +- [ ] python/ast_extract.py:216 - print() call – consider using logging instead. +- [ ] python/ast_extract.py:216 - `print` found +- [ ] python/ast_extract.py:135 - Magic number in comparison: 120 +- [ ] python/ast_extract.py:135 - Magic number: 120 - use named constant +- [ ] python/ast_extract.py:56 - Magic number: 2000 - use named constant +- [ ] python/ast_extract.py:220 - module execution block +- [ ] python/ast_extract.py:41 - Import used only once: argparse +- [ ] python/ast_extract.py:61 - Import used only once: hashlib +- [ ] python/ast_extract.py:91 - Import used only once: sys +- [ ] scripts/vallm-compatible.py:4 - Import block is not sorted (stdlib → third-party → local). +- [ ] scripts/vallm-compatible.py:13 - Function 'detect_file_language_with_parser_id' missing return type annotation. +- [ ] scripts/vallm-compatible.py:4 - Import block is un-sorted or un-formatted +- [ ] scripts/vallm-compatible.py:13 - Function 'detect_file_language_with_parser_id' missing return type (suggested: -> Any) +- [ ] scripts/vallm-compatible.py:24 - module execution block +- [ ] scripts/vallm-compatible.py:31 - Import used only once: SimpleNamespace +- [ ] scripts/vallm-compatible.py:51 - Import used only once: vallm +- [ ] scripts/vallm-compatible.py:61 - Import used only once: app +- [ ] scripts/research/evaluate-embedding-pairs.py:4 - Unused import: 'annotations' +- [ ] scripts/research/evaluate-embedding-pairs.py:4 - Import block is not sorted (stdlib → third-party → local). +- [ ] scripts/research/evaluate-embedding-pairs.py:82 - String concatenation could use f-string. +- [ ] scripts/research/evaluate-embedding-pairs.py:85 - print() call – consider using logging instead. +- [ ] scripts/research/evaluate-embedding-pairs.py:85 - `print` found +- [ ] scripts/research/evaluate-embedding-pairs.py:57 - String concatenation can be converted to f-string +- [ ] scripts/research/evaluate-embedding-pairs.py:58 - String concatenation can be converted to f-string +- [ ] scripts/research/evaluate-embedding-pairs.py:82 - String concatenation can be converted to f-string +- [ ] scripts/research/evaluate-embedding-pairs.py:100 - module execution block +- [ ] scripts/research/evaluate-embedding-pairs.py:101 - Import used only once: SentenceTransformer +- [ ] sdk/python/__init__.py:1 - Relative import (level=1): '.todo2code_sdk' +- [ ] sdk/python/__init__.py:1 - Relative import not allowed (level=1) +- [ ] sdk/python/todo2code/__init__.py:8 - Relative import (level=1): '.client' +- [ ] sdk/python/todo2code/__init__.py:18 - Relative import (level=1): '.runtime' +- [ ] sdk/python/todo2code/__init__.py:171 - Import used only once: RuntimeResult +- [ ] sdk/python/todo2code/__init__.py:8 - Relative import not allowed (level=1) +- [ ] sdk/python/todo2code/__init__.py:18 - Relative import not allowed (level=1) +- [ ] sdk/python/todo2code/runtime.py:8 - Unused import: 'annotations' +- [ ] sdk/python/todo2code/runtime.py:8 - Import block is not sorted (stdlib → third-party → local). +- [ ] sdk/python/todo2code/runtime.py:47 - Magic number: 300.0 - use named constant +- [ ] sdk/python/todo2code/runtime.py:157 - Magic number: 30 - use named constant +- [ ] sdk/python/todo2code/runtime.py:111 - Import used only once: shutil +- [ ] sdk/python/todo2code/runtime.py:141 - Import used only once: dataclass +- [ ] sdk/python/todo2code/client.py:3 - Unused import: 'annotations' +- [ ] sdk/python/todo2code/client.py:8 - Duplicate import: 'urllib' (first at line 7) +- [ ] sdk/python/todo2code/client.py:3 - Import block is not sorted (stdlib → third-party → local). +- [ ] sdk/python/todo2code/client.py:270 - String concatenation could use f-string. +- [ ] sdk/python/todo2code/client.py:270 - String concatenation can be converted to f-string +- [ ] sdk/python/todo2code/client.py:8 - Duplicate import: urllib +- [ ] sdk/python/todo2code/client.py:184 - Magic number: 120.0 - use named constant +- [ ] sdk/python/todo2code/client.py:41 - Magic number: 32000 - use named constant +- [ ] sdk/python/todo2code/client.py:275 - Magic number: 32001 - use named constant +- [ ] sdk/python/todo2code/client.py:172 - LLM-style docstring in T2CClient +- [ ] sdk/python/todo2code_sdk.py:15 - Relative import (level=1): '.todo2code.client' +- [ ] sdk/python/todo2code_sdk.py:16 - Relative import (level=1): '.todo2code.runtime' +- [ ] sdk/python/todo2code_sdk.py:9 - Unused import: 'annotations' +- [ ] sdk/python/todo2code_sdk.py:15 - Import block is un-sorted or un-formatted +- [ ] sdk/python/todo2code_sdk.py:19 - Import block is un-sorted or un-formatted +- [ ] sdk/python/todo2code_sdk.py:48 - Magic number: 120.0 - use named constant +- [ ] sdk/python/todo2code_sdk.py:110 - Magic number: 400 - use named constant +- [ ] sdk/python/todo2code_sdk.py:127 - Magic number: 50 - use named constant +- [ ] sdk/python/todo2code_sdk.py:151 - Import used only once: RuntimeResult +- [ ] sdk/python/todo2code_sdk.py:191 - Import used only once: RuntimeResult +- [ ] sdk/python/todo2code_sdk.py:15 - Relative import not allowed (level=1) +- [ ] sdk/python/todo2code_sdk.py:16 - Relative import not allowed (level=1) +- [ ] src/interfaces/intake_cli.py:10 - Unused import: 'annotations' +- [ ] src/interfaces/intake_cli.py:148 - String concatenation could use f-string. +- [ ] src/interfaces/intake_cli.py:114 - print() call – consider using logging instead. +- [ ] src/interfaces/intake_cli.py:151 - print() call – consider using logging instead. +- [ ] src/interfaces/intake_cli.py:114 - `print` found +- [ ] src/interfaces/intake_cli.py:151 - `print` found +- [ ] src/interfaces/intake_cli.py:10 - Import block is un-sorted or un-formatted +- [ ] src/interfaces/intake_cli.py:94 - String concatenation can be converted to f-string +- [ ] src/interfaces/intake_cli.py:148 - String concatenation can be converted to f-string +- [ ] src/interfaces/intake_cli.py:57 - Magic number: 11 - use named constant +- [ ] src/interfaces/intake_cli.py:33 - Magic number: 127 - use named constant +- [ ] src/interfaces/intake_cli.py:48 - Magic number: 127 - use named constant +- [ ] src/interfaces/intake_cli.py:155 - module execution block +- [ ] src/interfaces/intake_cli.py:151 - Import used only once: subprocess +- [ ] scripts/research/rank-intent-graph-embeddings.py:4 - Unused import: 'annotations' +- [ ] scripts/research/rank-intent-graph-embeddings.py:4 - Import block is not sorted (stdlib → third-party → local). +- [ ] scripts/research/rank-intent-graph-embeddings.py:157 - String concatenation could use f-string. +- [ ] scripts/research/rank-intent-graph-embeddings.py:160 - print() call – consider using logging instead. +- [ ] scripts/research/rank-intent-graph-embeddings.py:160 - `print` found +- [ ] scripts/research/rank-intent-graph-embeddings.py:29 - String concatenation can be converted to f-string +- [ ] scripts/research/rank-intent-graph-embeddings.py:157 - String concatenation can be converted to f-string +- [ ] scripts/research/rank-intent-graph-embeddings.py:173 - module execution block +- [ ] scripts/research/rank-intent-graph-embeddings.py:61 - Import used only once: hashlib +- [ ] scripts/research/rank-intent-graph-embeddings.py:111 - Import used only once: SentenceTransformer + +--- + +*To execute all tasks, run: `prefact -a --execute-todos`* + diff --git a/app.doql.less b/app.doql.less new file mode 100644 index 00000000..8f841380 --- /dev/null +++ b/app.doql.less @@ -0,0 +1,225 @@ +// LESS format — define @variables here as needed + +app { + name: todo2code; + version: 0.5.1; +} + +workflow[name="setup"] { + trigger: manual; + step-1: run cmd=test -f .env || cp .env.example .env; + step-2: run cmd=$(NPM) install --omit=optional; +} + +workflow[name="install"] { + trigger: manual; + step-1: run cmd=$(NPM) install --omit=optional; +} + +workflow[name="install-tf"] { + trigger: manual; + step-1: run cmd=$(NPM) --prefix adapters/tensorflow install; +} + +workflow[name="build"] { + trigger: manual; + step-1: run cmd=$(NPM) run build; +} + +workflow[name="check"] { + trigger: manual; + step-1: run cmd=$(NPM) run check; +} + +workflow[name="test"] { + trigger: manual; + step-1: run cmd=$(NPM) test; +} + +workflow[name="verify-no-llm"] { + trigger: manual; + step-1: run cmd=$(NPM) run verify:no-llm; +} + +workflow[name="verify-modules"] { + trigger: manual; + step-1: run cmd=$(NPM) run verify:modules; +} + +workflow[name="verify-env"] { + trigger: manual; + step-1: run cmd=$(NPM) run verify:env; +} + +workflow[name="verify"] { + trigger: manual; + step-1: run cmd=$(NPM) run verify; +} + +workflow[name="governance"] { + trigger: manual; + step-1: run cmd=bash project/governance-check.sh --actor agent; +} + +workflow[name="smoke"] { + trigger: manual; + step-1: run cmd=bash scripts/smoke.sh; +} + +workflow[name="doctor"] { + trigger: manual; + step-1: run cmd=$(NODE) dist/src/cli.js doctor; +} + +workflow[name="mcp-probe"] { + trigger: manual; + step-1: run cmd=bash scripts/mcp-request.sh; +} + +workflow[name="a2a-probe"] { + trigger: manual; + step-1: run cmd=bash scripts/a2a-request.sh; +} + +workflow[name="protocol-smoke"] { + trigger: manual; + step-1: depend target=mcp-probe; + step-2: depend target=a2a-probe; +} + +workflow[name="validate"] { + trigger: manual; + step-1: depend target=verify; + step-2: depend target=smoke; + step-3: depend target=protocol-smoke; + step-4: depend target=doctor; + step-5: depend target=docker-smoke; +} + +workflow[name="live-contract-check"] { + trigger: manual; + step-1: run cmd=$(NPM) run live:check; +} + +workflow[name="live-model-comparison"] { + trigger: manual; + step-1: run cmd=$(NPM) run live:models; +} + +workflow[name="demo"] { + trigger: manual; + step-1: run cmd=OPENROUTER_API_KEY= T2C_NL_MODE=deterministic T2C_MARKDOWN_MODE=deterministic T2C_COMMUNICATION_MODE=deterministic $(NODE) dist/src/cli.js pipeline examples --task task.md --todo TODO.md --changelog CHANGELOG.md --docs 'docs/**/*.md' --no-docs-llm --no-summary-llm --out .intent-demo; + step-2: run cmd=T2C_COMMUNICATION_MODE=deterministic $(NODE) dist/src/cli.js communication examples --project-dir project --ticket DEMO-101 --out examples/.intent-communication/analysis.json --md examples/.intent-communication/analysis.md --graph examples/.intent-communication/graph.json; +} + +workflow[name="demollm"] { + trigger: manual; + step-1: run cmd=OPENROUTER_TIMEOUT_MS=300000 T2C_DOC_TIMEOUT_MS=300000 $(NODE) dist/src/cli.js pipeline examples --task task.md --todo TODO.md --changelog CHANGELOG.md --docs 'docs/**/*.md' --nl-mode require-llm --markdown-mode require-llm --communication-mode require-llm --summary-fallback false --task-mode require-llm --out .intent-demo-llm; + step-2: run cmd=$(NODE) scripts/assert-demollm-run.mjs examples .intent-demo-llm; +} + +workflow[name="examples-check"] { + trigger: manual; + step-1: run cmd=bash scripts/examples-check.sh; +} + +workflow[name="pipeline"] { + trigger: manual; + step-1: run cmd=$(NODE) dist/src/cli.js pipeline "$(ROOT)" --task "$(TASK)" --todo "$(TODO)" --changelog "$(CHANGELOG)" --docs "$(DOCS)" --out "$(OUT)"; +} + +workflow[name="compare-workspace"] { + trigger: manual; + step-1: run cmd=$(NODE) dist/src/cli.js compare-workspace "$(ROOT)" --base "$${BASE_REF:-origin/main}" --out "$(OUT)"; +} + +workflow[name="preflight"] { + trigger: manual; + step-1: run cmd=$(NPM) run build >&2; + step-2: run cmd=$(NODE) scripts/workspace-preflight.mjs \; + step-3: run cmd=--root $(call shell_quote,PREFLIGHT_ROOT) \; + step-4: run cmd=--baseline $(call shell_quote,PREFLIGHT_BASELINE) \; + step-5: run cmd=--expected-branch $(call shell_quote,PREFLIGHT_EXPECTED_BRANCH) \; + step-6: run cmd=--actor $(call shell_quote,PREFLIGHT_ACTOR); +} + +workflow[name="mcp"] { + trigger: manual; + step-1: run cmd=$(NODE) dist/src/interfaces/mcp.js; +} + +workflow[name="a2a"] { + trigger: manual; + step-1: run cmd=$(NODE) dist/src/interfaces/a2a.js; +} + +workflow[name="docker-build"] { + trigger: manual; + step-1: run cmd=docker build -t todo2code:local .; +} + +workflow[name="docker-smoke"] { + trigger: manual; + step-1: run cmd=bash scripts/docker-smoke.sh; +} + +workflow[name="docker-up"] { + trigger: manual; + step-1: run cmd=docker compose -f docker-compose.yml up --build -d; +} + +workflow[name="docker-down"] { + trigger: manual; + step-1: run cmd=docker compose -f docker-compose.yml down; +} + +workflow[name="e2e-core"] { + trigger: manual; + step-1: run cmd=docker compose -f $(E2E_COMPOSE) build e2e-core; + step-2: run cmd=docker compose -f $(E2E_COMPOSE) run --rm --no-deps e2e-core; +} + +workflow[name="e2e-full"] { + trigger: manual; + step-1: run cmd=docker compose -f $(E2E_COMPOSE) build e2e-full; + step-2: run cmd=docker compose -f $(E2E_COMPOSE) run --rm --no-deps e2e-full; +} + +workflow[name="e2e-clean"] { + trigger: manual; + step-1: run cmd=docker compose -f $(E2E_COMPOSE) down --remove-orphans --rmi all; +} + +workflow[name="python-wheel"] { + trigger: manual; + step-1: run cmd=mkdir -p "$(PYTHON_WHEEL_DIR)"; + step-2: run cmd=$(PYTHON) -m pip wheel . --no-deps --wheel-dir "$(PYTHON_WHEEL_DIR)"; +} + +workflow[name="package"] { + trigger: manual; + step-1: run cmd=$(PYTHON) scripts/package.py "$(PACKAGE)"; +} + +workflow[name="clean"] { + trigger: manual; + step-1: run cmd=rm -rf dist coverage .intent-demo .intent-test *.zip; +} + +env_vars { + keys: T2C_ENV_FILE, T2C_ROOT, T2C_OUTPUT_DIR, T2C_GIT_COMMIT_COUNT, T2C_MAX_FILE_BYTES, T2C_DOC_CONCURRENCY, T2C_DOC_CHUNK_CHARS, T2C_DOC_MAX_CHUNKS, T2C_DOC_MAX_RECORDS_PER_CHUNK, T2C_DOC_TIMEOUT_MS, T2C_PYTHON, T2C_ENABLE_PYTHON_AST, T2C_GO, T2C_ENABLE_GO_AST, T2C_JAVA, T2C_ENABLE_JAVA_AST, T2C_CARGO, T2C_ENABLE_RUST_AST, T2C_PHP, T2C_ENABLE_PHP_AST, T2C_ALLOW_OUTSIDE_ROOT, T2C_ENABLE_TF, T2C_TF_MODEL_PATH, T2C_TF_MODULE_PATH, T2C_TF_LABELS, T2C_NL_MODE, T2C_MARKDOWN_MODE, T2C_COMMUNICATION_MODE, OPENROUTER_API_KEY, OPENROUTER_BASE_URL, OPENROUTER_MODEL, OPENROUTER_NL_MODEL, OPENROUTER_MARKDOWN_MODEL, OPENROUTER_COMMUNICATION_MODEL, OPENROUTER_DOC_MODEL, OPENROUTER_SUMMARY_MODEL, OPENROUTER_TASK_MODEL, OPENROUTER_SITE_URL, OPENROUTER_APP_NAME, OPENROUTER_TIMEOUT_MS, OPENROUTER_MAX_TOKENS, OPENROUTER_TEMPERATURE, OPENROUTER_REQUIRE_STRUCTURED_OUTPUT, OPENROUTER_RESPONSE_HEALING, T2C_REQUIRE_LIVE_CHECK, T2C_LIVE_AUDIT_PATH, T2C_LIVE_HISTORY_PATH, T2C_LIVE_RUN_OUTPUT, T2C_LIVE_MAX_STAGE_LATENCY_MS, T2C_LIVE_MAX_LATENCY_MS, T2C_LIVE_MAX_TOTAL_LATENCY_MS, T2C_LIVE_MAX_COST_USD, T2C_LIVE_COMPARE_MODELS, T2C_LIVE_COMPARE_ROOT, T2C_LIVE_COMPARE_TIMEOUT_MS, T2C_LIVE_COMPARE_PATH, T2C_LIVE_COMPARE_MD_PATH, T2C_DOC_PATTERNS, T2C_MARKDOWN_CONCURRENCY, T2C_DOC_EXCLUDES, T2C_MCP_SERVER_NAME, T2C_MCP_SERVER_VERSION, T2C_A2A_HOST, T2C_A2A_PORT, T2C_A2A_PUBLIC_URL, T2C_A2A_TOKEN, T2C_A2A_MAX_BODY_BYTES, T2C_A2A_TASK_STORE, T2C_WORKSPACE, T2C_DOCKER_HOST_PORT, T2C_A2A_URL, T2C_EXAMPLE_ROOT, T2C_COMPARE_WORKSPACE, T2C_COMPARE_BASE, T2C_TYPESCRIPT_CLI; +} + +deploy { + target: docker-compose; + compose_file: docker-compose.yml; +} + +environment[name="local"] { + runtime: docker-compose; + env_file: .env.example; + template_file: .env.example; + python_version: >=3.10; + vars: OPENROUTER_API_KEY, OPENROUTER_APP_NAME, OPENROUTER_BASE_URL, OPENROUTER_COMMUNICATION_MODEL, OPENROUTER_DOC_MODEL, OPENROUTER_MARKDOWN_MODEL, OPENROUTER_MAX_TOKENS, OPENROUTER_MODEL, OPENROUTER_NL_MODEL, OPENROUTER_REQUIRE_STRUCTURED_OUTPUT, OPENROUTER_RESPONSE_HEALING, OPENROUTER_SITE_URL, OPENROUTER_SUMMARY_MODEL, OPENROUTER_TASK_MODEL, OPENROUTER_TEMPERATURE, OPENROUTER_TIMEOUT_MS, T2C_A2A_HOST, T2C_A2A_MAX_BODY_BYTES, T2C_A2A_PORT, T2C_A2A_PUBLIC_URL, T2C_A2A_TASK_STORE, T2C_A2A_TOKEN, T2C_A2A_URL, T2C_ALLOW_OUTSIDE_ROOT, T2C_CARGO, T2C_COMMUNICATION_MODE, T2C_COMPARE_BASE, T2C_COMPARE_WORKSPACE, T2C_DOCKER_HOST_PORT, T2C_DOC_CHUNK_CHARS, T2C_DOC_CONCURRENCY, T2C_DOC_EXCLUDES, T2C_DOC_MAX_CHUNKS, T2C_DOC_MAX_RECORDS_PER_CHUNK, T2C_DOC_PATTERNS, T2C_DOC_TIMEOUT_MS, T2C_ENABLE_GO_AST, T2C_ENABLE_JAVA_AST, T2C_ENABLE_PHP_AST, T2C_ENABLE_PYTHON_AST, T2C_ENABLE_RUST_AST, T2C_ENABLE_TF, T2C_ENV_FILE, T2C_EXAMPLE_ROOT, T2C_GIT_COMMIT_COUNT, T2C_GO, T2C_JAVA, T2C_LIVE_AUDIT_PATH, T2C_LIVE_COMPARE_MD_PATH, T2C_LIVE_COMPARE_MODELS, T2C_LIVE_COMPARE_PATH, T2C_LIVE_COMPARE_ROOT, T2C_LIVE_COMPARE_TIMEOUT_MS, T2C_LIVE_HISTORY_PATH, T2C_LIVE_MAX_COST_USD, T2C_LIVE_MAX_LATENCY_MS, T2C_LIVE_MAX_STAGE_LATENCY_MS, T2C_LIVE_MAX_TOTAL_LATENCY_MS, T2C_LIVE_RUN_OUTPUT, T2C_MARKDOWN_CONCURRENCY, T2C_MARKDOWN_MODE, T2C_MAX_FILE_BYTES, T2C_MCP_SERVER_NAME, T2C_MCP_SERVER_VERSION, T2C_NL_MODE, T2C_OUTPUT_DIR, T2C_PHP, T2C_PYTHON, T2C_REQUIRE_LIVE_CHECK, T2C_ROOT, T2C_TF_LABELS, T2C_TF_MODEL_PATH, T2C_TF_MODULE_PATH, T2C_TYPESCRIPT_CLI, T2C_WORKSPACE; + runtime_llm: OPENROUTER_API_KEY, OPENROUTER_APP_NAME, OPENROUTER_BASE_URL, OPENROUTER_COMMUNICATION_MODEL, OPENROUTER_DOC_MODEL, OPENROUTER_MARKDOWN_MODEL, OPENROUTER_MAX_TOKENS, OPENROUTER_MODEL, OPENROUTER_NL_MODEL, OPENROUTER_REQUIRE_STRUCTURED_OUTPUT, OPENROUTER_RESPONSE_HEALING, OPENROUTER_SITE_URL, OPENROUTER_SUMMARY_MODEL, OPENROUTER_TASK_MODEL, OPENROUTER_TEMPERATURE, OPENROUTER_TIMEOUT_MS; +} diff --git a/examples/backend/src/server.ts b/examples/backend/src/server.ts index 8cbdc68f..f976f2e2 100644 --- a/examples/backend/src/server.ts +++ b/examples/backend/src/server.ts @@ -34,39 +34,59 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse, } if (request.method === 'POST' && url.pathname === '/events') { - const body = await readBody(request); - let payload: unknown; - try { - payload = JSON.parse(body || '{}'); - } catch { - sendJson(response, 400, { error: 'invalid JSON body' }); - return; - } - const validation = validateEventPayload(payload); - if (!validation.valid) { - // Every rejection is logged with its reason, per the acceptance criteria. - process.stderr.write(`rejected event: ${validation.reason}\n`); - sendJson(response, 400, { error: validation.reason }); - return; - } - const event = store.enqueueEvent(validation.agent, validation.action, validation.object); - sendJson(response, 202, { id: event.id }); + await handlePostEvent(request, response, store); return; } if (request.method === 'GET' && url.pathname === '/events') { - const offset = Number(url.searchParams.get('offset') ?? '0'); - const limit = Number(url.searchParams.get('limit') ?? '50'); - sendJson(response, 200, store.listEvents( - Number.isFinite(offset) ? offset : 0, - Number.isFinite(limit) ? limit : 50, - )); + handleListEvents(url, response, store); return; } sendJson(response, 404, { error: 'not found' }); } +async function handlePostEvent( + request: IncomingMessage, + response: ServerResponse, + store: EventStore, +): Promise { + const payload = await parseJsonBody(request); + if (payload === undefined) { + sendJson(response, 400, { error: 'invalid JSON body' }); + return; + } + const validation = validateEventPayload(payload); + if (!validation.valid) { + // Every rejection is logged with its reason, per the acceptance criteria. + process.stderr.write(`rejected event: ${validation.reason}\n`); + sendJson(response, 400, { error: validation.reason }); + return; + } + const event = store.enqueueEvent(validation.agent, validation.action, validation.object); + sendJson(response, 202, { id: event.id }); +} + +function handleListEvents(url: URL, response: ServerResponse, store: EventStore): void { + const offset = finiteQueryNumber(url, 'offset', 0); + const limit = finiteQueryNumber(url, 'limit', 50); + sendJson(response, 200, store.listEvents(offset, limit)); +} + +function finiteQueryNumber(url: URL, name: string, fallback: number): number { + const value = Number(url.searchParams.get(name) ?? String(fallback)); + return Number.isFinite(value) ? value : fallback; +} + +async function parseJsonBody(request: IncomingMessage): Promise { + const body = await readBody(request); + try { + return JSON.parse(body || '{}'); + } catch { + return undefined; + } +} + async function readBody(request: IncomingMessage): Promise { const chunks: Buffer[] = []; let size = 0; diff --git a/prefact.yaml b/prefact.yaml new file mode 100644 index 00000000..fef2a0bf --- /dev/null +++ b/prefact.yaml @@ -0,0 +1,94 @@ +project_root: . +package_name: '' +include: +- '**/*.py' +exclude: +- '**/__pycache__/**' +- '**/node_modules/**' +- '**/.venv/**' +- '**/venv/**' +- '**/env/**' +- '**/.env/**' +- '**/site-packages/**' +- '**/.git/**' +- '**/build/**' +- '**/dist/**' +- '**/*.egg-info/**' +- '**/tests/**' +tools: + parallel: true + cache: true + ruff: + enabled: true + max_line_length: 88 + select: + - E + - F + - W + - I + ignore: + - E501 + mypy: + enabled: true + strict: false + ignore_missing_imports: true + isort: + enabled: true + profile: black + multi_line_output: 3 +performance: + max_workers: 4 + cache_size: 104857600 + chunk_size: 10 + autonomous_max_examples_per_issue: 3 + autonomous_max_issues: 500 + autonomous_max_files_to_scan: 1000 + autonomous_max_issues_per_file: 50 + autonomous_max_run_seconds: 300 + autonomous_max_tickets: 100 + autonomous_max_todo_items: 200 + autonomous_max_completed_todos: 100 + autonomous_max_todo_execution_items: 50 +rules: + unused-imports: + enabled: true + tools: + - ruff + - autoflake + severity: error + relative-imports: + enabled: true + tools: + - libcst + auto_fix: true + missing-return-type: + enabled: true + tools: + - mypy + severity: warning + llm-hallucinations: + enabled: true + magic-numbers: + enabled: true +plugins: + enabled: true + directories: + - ~/.prefact/plugins + - ./.prefact/plugins +environments: + development: + rules: + print-statements: + enabled: true + magic-numbers: + enabled: false + performance: + max_workers: 2 + production: + rules: + print-statements: + enabled: false + magic-numbers: + enabled: true + performance: + max_workers: 8 diff --git a/project2.sh b/project2.sh new file mode 100755 index 00000000..2ef9d6df --- /dev/null +++ b/project2.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -e + +VENV="venv" +PIP="$VENV/bin/pip" +# Generated/derived artifacts (schema pipeline: Pydantic → JSON Schema → Zod) — kept +# out of the project map since the source of truth is the Pydantic models, not these. +CODE2LLM_GENERATED="--exclude frontend/src/generated/** --exclude shared/schemas/** --exclude backend/generated/** --exclude desktop/src-tauri/gen/schemas/** --exclude testql-testing/scenarios/generated/** --exclude generated/**" +CODE2LLM_EXCLUDES="--exclude .venv-test/** --exclude venv/** --exclude .venv/** --exclude node_modules/** --exclude .code2llm_cache/** --exclude .playwright-browsers/** --exclude build/** --exclude dist/** --exclude .koru/** --exclude .regres/** $CODE2LLM_GENERATED" + +# Quick helper: run backend access-code management without executing +# the heavy project generation pipeline below. +if [ "${1:-}" = "access-codes" ]; then + shift + exec ./backend/scripts/set_user_access_codes_local.sh "$@" +fi + +clear || true + +if [ ! -f "$PIP" ]; then + echo "Creating virtual environment..." + python3 -m venv "$VENV" +fi + +$PIP install regix --upgrade --quiet +#$PIP install pyqual --upgrade --quiet +$PIP install prefact --upgrade --quiet +#$PIP install vallm --upgrade --quiet + +$PIP install glon --upgrade --quiet +$PIP install goal --upgrade --quiet +$PIP install code2logic --upgrade --quiet + +$PIP install code2llm --upgrade --quiet +#$VENV/bin/code2llm ./ -f toon,evolution,code2logic,project-yaml -o ./project --no-chunk +$VENV/bin/code2llm ./ -f all -o ./project --no-chunk $CODE2LLM_EXCLUDES +#$VENV/bin/code2llm report --format all # → all views + +$PIP install redup --upgrade --quiet +$VENV/bin/redup scan . --format toon --output ./project +#$VENV/bin/redup scan . --functions-only -f toon --output ./project +#$VENV/bin/vallm batch ./src --recursive --semantic --model qwen2.5-coder:7b +#$VENV/bin/vallm batch --parallel . +#$VENV/bin/vallm batch . --recursive --format toon --output ./project +$VENV/bin/prefact -a -e "examples/**" + +$PIP install doql --upgrade --quiet +$VENV/bin/doql adopt . --format less --output app.doql.less --force + +$PIP install sumd --upgrade --quiet +$VENV/bin/sumd . + +$PIP install code2docs --upgrade --quiet +$VENV/bin/code2docs ./ --readme-only +$VENV/bin/sumr . diff --git a/src/communication/communication-llm-enrich.ts b/src/communication/communication-llm-enrich.ts new file mode 100644 index 00000000..603087d3 --- /dev/null +++ b/src/communication/communication-llm-enrich.ts @@ -0,0 +1,87 @@ +import type { T2CConfig } from '../config/env.js'; +import type { IntentRecord, LlmExtractionMode, LlmResponseMetadata } from '../core/types.js'; +import type { CommunicationExtractionOptions } from '../extractors/communication.js'; +import { classifyLlmFailure, rejectedLlmResponseMetadata, type LlmFailureReason } from '../llm/failure.js'; +import { OpenRouterClient, type OpenRouterResult } from '../llm/openrouter.js'; +import { StructuredResponseError } from '../llm/structured-schema.js'; +import { COMMUNICATION_RESPONSE_CONTRACT } from './communication-llm-schema.js'; +import { + communicationAudit, + deterministicSyntheses, + fallbackGeneration, + markDeterministic, +} from './communication-llm-helpers.js'; +import type { AuditedCommunicationExtractionResult, RawCommunicationResponse } from './communication-llm-types.js'; +import { CommunicationLlmRequiredError } from './communication-llm-types.js'; + +export class CommunicationAttemptError extends Error { + constructor(readonly failure: unknown, readonly responses: LlmResponseMetadata[]) { + super(failure instanceof Error ? failure.message : String(failure)); + this.name = 'CommunicationAttemptError'; + } +} + +export async function enrichWithCorrection( + client: OpenRouterClient, + baseMessages: Array<{ role: 'system' | 'user'; content: string }>, + model: string, +): Promise<{ completion: OpenRouterResult; responses: LlmResponseMetadata[] }> { + const responses: LlmResponseMetadata[] = []; + let correction: string | null = null; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const completion = await client.chatStructuredWithMetadata([ + ...baseMessages, + ...(correction + ? [{ + role: 'user' as const, + content: `The previous response was rejected: ${correction}\n` + + 'Correct exactly that violation and re-emit the full object. Do not add, rename, or omit properties.\n' + + `The exact required JSON Schema is: ${JSON.stringify(COMMUNICATION_RESPONSE_CONTRACT.jsonSchema)}`, + }] + : []), + ], 't2c_communication_enrichment', COMMUNICATION_RESPONSE_CONTRACT, model); + responses.push(completion.metadata); + return { completion, responses }; + } catch (error) { + if (error instanceof StructuredResponseError) { + if (error.responseMetadata) responses.push(error.responseMetadata); + if (attempt === 0) { + correction = error.message; + continue; + } + } + throw new CommunicationAttemptError(error, [...responses]); + } + } + + throw new CommunicationAttemptError(new Error('Communication correction retry budget exhausted'), responses); +} + +export async function fallbackOrThrow( + records: IntentRecord[], + warnings: string[], + config: T2CConfig, + options: CommunicationExtractionOptions, + mode: LlmExtractionMode, + startedAt: number, + reason: LlmFailureReason, + responses: LlmResponseMetadata[] = [], +): Promise { + const failed = communicationAudit('failed', 'llm', 'none', true, 0, warnings.length + 1, + config.openRouter.communicationModel, reason, Date.now() - startedAt, responses, config, options); + if (mode === 'require-llm') { + throw new CommunicationLlmRequiredError(`Communication enrichment requires LLM: ${reason.message}`, failed); + } + const warning = `Communication enrichment used deterministic fallback (${reason.code}): ${reason.message}`; + const marked = markDeterministic(records, true, reason.code); + return { + schemaVersion: 't2c.communication-enrichment/v1', + records: marked, + participants: deterministicSyntheses(marked, fallbackGeneration(reason.code)), + warnings: [...warnings, warning], + audit: communicationAudit('fallback', 'llm', 'deterministic', true, marked.length, warnings.length + 1, + config.openRouter.communicationModel, reason, Date.now() - startedAt, responses, config, options), + }; +} \ No newline at end of file diff --git a/src/communication/communication-llm-helpers.ts b/src/communication/communication-llm-helpers.ts new file mode 100644 index 00000000..ef89228a --- /dev/null +++ b/src/communication/communication-llm-helpers.ts @@ -0,0 +1,296 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { T2CConfig } from '../config/env.js'; +import { createIntentId, sha256, stableStringify } from '../core/id.js'; +import { pathExists } from '../core/io.js'; +import { buildRecord, withRecordGeneration } from '../core/record.js'; +import type { + GroundedGenerationMetadata, + IntentRecord, + LlmExtractionMode, + LlmResponseMetadata, + PipelineStageAudit, +} from '../core/types.js'; +import type { CommunicationExtractionOptions, CommunicationRole } from '../extractors/communication.js'; +import { openRouterAuditConfiguration } from '../llm/audit.js'; +import { T2C_VERSION } from '../version.js'; +import type { + ParticipantCommunicationSynthesis, + ParticipantGroup, + RawCommunicationEnrichment, + RawParticipantSynthesis, +} from './communication-llm-types.js'; + +export function participantGroups(records: IntentRecord[]): ParticipantGroup[] { + const grouped = new Map(); + for (const record of records) { + const participant = String(record.metadata.participant ?? record.statement.actor ?? `unknown:${record.id}`); + const role = roleOf(record); + const key = stableStringify({ participant, role }); + const values = grouped.get(key); + if (values) values.push(record); + else grouped.set(key, [record]); + } + return [...grouped.values()].map((values, index) => ({ + key: `participant-${index + 1}`, + participant: String(values[0]?.metadata.participant ?? values[0]?.statement.actor ?? 'unknown'), + role: roleOf(values[0]), + tickets: [...new Set(values.flatMap((record) => record.statement.target.tickets))].sort(), + records: [...values].sort((left, right) => left.id.localeCompare(right.id)), + })).sort((left, right) => left.role.localeCompare(right.role) || left.participant.localeCompare(right.participant)) + .map((group, index) => ({ ...group, key: `participant-${index + 1}` })); +} + +export function promptPayload(records: IntentRecord[], groups: ParticipantGroup[]): Record { + return { + records: records.map((record) => ({ + recordId: record.id, + participantKey: groups.find((group) => group.records.some((item) => item.id === record.id))?.key, + text: record.statement.text, + deterministic: { + action: record.statement.action, + object: record.statement.object, + polarity: record.statement.polarity, + paths: record.statement.target.paths, + symbols: record.statement.target.symbols, + versions: record.statement.target.versions, + }, + })), + participants: groups.map((group) => ({ + participantKey: group.key, + recordIds: group.records.map((record) => record.id), + })), + }; +} + +export function validateEnrichments( + values: RawCommunicationEnrichment[] | undefined, + records: IntentRecord[], +): Map { + if (!Array.isArray(values)) throw new Error('Structured response does not contain communication enrichments'); + const expected = new Set(records.map((record) => record.id)); + const output = new Map(); + for (const value of values) { + if (!expected.has(value.recordId)) throw new Error(`Structured response contains unknown recordId: ${value.recordId}`); + if (output.has(value.recordId)) throw new Error(`Structured response duplicates recordId: ${value.recordId}`); + output.set(value.recordId, value); + } + if (output.size !== expected.size) throw new Error(`Structured response returned ${output.size} of ${expected.size} enrichments`); + return output; +} + +export function materializeSyntheses( + values: RawParticipantSynthesis[] | undefined, + groups: ParticipantGroup[], + enrichedByOriginal: Map, + generation: GroundedGenerationMetadata, +): ParticipantCommunicationSynthesis[] { + if (!Array.isArray(values)) throw new Error('Structured response does not contain participantSyntheses'); + const byKey = new Map(groups.map((group) => [group.key, group])); + const seen = new Set(); + const output = values.map((raw) => { + const group = byKey.get(raw.participantKey); + if (!group) throw new Error(`Structured response contains unknown participantKey: ${raw.participantKey}`); + if (seen.has(raw.participantKey)) throw new Error(`Structured response duplicates participantKey: ${raw.participantKey}`); + seen.add(raw.participantKey); + const permitted = new Set(group.records.map((record) => record.id)); + if (!raw.recordIds.length || raw.recordIds.some((id) => !permitted.has(id))) { + throw new Error(`Participant synthesis ${raw.participantKey} contains ungrounded recordIds`); + } + const recordIds = raw.recordIds.map((id) => enrichedByOriginal.get(id)?.id) + .filter((id): id is string => Boolean(id)).sort(); + return buildSynthesis({ + participant: group.participant, + role: group.role, + tickets: group.tickets, + summary: raw.summary, + commitments: raw.commitments, + risks: raw.risks, + recordIds, + confidence: raw.confidence, + generation, + }); + }); + if (seen.size !== groups.length) throw new Error(`Structured response returned ${seen.size} of ${groups.length} participant syntheses`); + return output.sort((left, right) => left.role.localeCompare(right.role) || left.participant.localeCompare(right.participant)); +} + +export function enrichRecord( + record: IntentRecord, + enrichment: RawCommunicationEnrichment, + config: T2CConfig, + response: LlmResponseMetadata, +): IntentRecord { + return buildRecord({ + kind: record.statement.kind, + actor: record.statement.actor, + action: enrichment.action, + subject: record.statement.subject, + object: enrichment.object.trim() || record.statement.object, + target: { + paths: [...record.statement.target.paths, ...enrichment.target.paths], + symbols: [...record.statement.target.symbols, ...enrichment.target.symbols], + tickets: record.statement.target.tickets, + versions: [...record.statement.target.versions, ...enrichment.target.versions], + }, + modality: record.statement.modality, + polarity: enrichment.polarity, + text: record.statement.text, + lifecycle: record.lifecycle.status, + sourceKind: record.source.kind, + sourcePath: record.source.path, + sourceLines: record.source.lines, + revision: record.source.revision, + symbol: record.source.symbol, + commitIndex: record.source.commitIndex, + extractor: 't2c/project-communication-openrouter@1', + rawExcerpt: record.source.rawExcerpt, + epistemicClass: record.epistemic.class, + confidence: Math.min(0.85, Math.max(0.05, enrichment.confidence)), + basis: [...record.epistemic.basis, 'openrouter_communication_enrichment', ...enrichment.basis], + observedAt: record.observedAt, + generation: { + requested: 'llm', used: 'llm', provider: response.provider ?? 'openrouter', + model: response.model ?? config.openRouter.communicationModel, responseId: response.responseId, + }, + metadata: { + ...record.metadata, + llmUsed: true, + topics: sortedUnique(enrichment.topics), + response, + }, + }); +} + +export function deterministicSyntheses( + records: IntentRecord[], + generation: GroundedGenerationMetadata, +): ParticipantCommunicationSynthesis[] { + return participantGroups(records).map((group) => buildSynthesis({ + participant: group.participant, + role: group.role, + tickets: group.tickets, + summary: `${group.participant} (${group.role}) ma ${group.records.length} uziemionych rekordów komunikacji.`, + commitments: group.records.filter((record) => record.epistemic.class === 'plan') + .map((record) => record.statement.text), + risks: group.records.filter((record) => record.statement.polarity === 'negative') + .map((record) => record.statement.text), + recordIds: group.records.map((record) => record.id).sort(), + confidence: 1, + generation, + })); +} + +export function buildSynthesis( + input: Omit, +): ParticipantCommunicationSynthesis { + const semantic = { + participant: input.participant, + role: input.role, + tickets: sortedUnique(input.tickets), + summary: input.summary.trim(), + commitments: sortedUnique(input.commitments), + risks: sortedUnique(input.risks), + recordIds: sortedUnique(input.recordIds), + }; + if (!semantic.summary || !semantic.recordIds.length) throw new Error('Participant synthesis requires a summary and record citations'); + return { + schemaVersion: 't2c.participant-synthesis/v1', + id: createIntentId(semantic, 'COMM-SYN'), + ...semantic, + confidence: Math.min(1, Math.max(0, input.confidence)), + generation: input.generation, + }; +} + +export function markDeterministic( + records: IntentRecord[], + degraded: boolean, + fallbackReason: string | null, +): IntentRecord[] { + return records.map((record) => { + const marked = withRecordGeneration(record, { + requested: degraded ? 'llm' : 'deterministic', used: 'deterministic', degraded, fallbackReason, + }); + return { ...marked, metadata: { ...marked.metadata, llmUsed: false } }; + }); +} + +export function deterministicGeneration(): GroundedGenerationMetadata { + return { + generator: 't2c/participant-synthesis', generatorVersion: '1', + runtimeVersion: T2C_VERSION, generatedAt: new Date().toISOString(), + requestedMode: 'deterministic', effectiveMode: 'deterministic', degraded: false, + model: null, provider: null, responseId: null, + configurationFingerprint: sha256('t2c-communication-deterministic/v1'), reason: null, + }; +} + +export function fallbackGeneration(reason: string): GroundedGenerationMetadata { + return { + generator: 't2c/participant-synthesis', generatorVersion: '1', + runtimeVersion: T2C_VERSION, generatedAt: new Date().toISOString(), + requestedMode: 'prefer-llm', effectiveMode: 'deterministic', degraded: true, + model: null, provider: null, responseId: null, + configurationFingerprint: sha256(stableStringify({ stage: 'communication', reason })), reason, + }; +} + +export function llmGeneration( + config: T2CConfig, + mode: LlmExtractionMode, + response: LlmResponseMetadata, +): GroundedGenerationMetadata { + return { + generator: 't2c/participant-synthesis', generatorVersion: '1', + runtimeVersion: T2C_VERSION, generatedAt: new Date().toISOString(), + requestedMode: mode, effectiveMode: 'llm', degraded: false, + model: response.model ?? config.openRouter.communicationModel, + provider: response.provider ?? 'openrouter', responseId: response.responseId, + configurationFingerprint: sha256(stableStringify(openRouterAuditConfiguration(config, config.openRouter.communicationModel))), + reason: null, + }; +} + +export function communicationAudit( + status: PipelineStageAudit['status'], + requestedMode: PipelineStageAudit['requestedMode'], + effectiveMode: PipelineStageAudit['effectiveMode'], + degraded: boolean, + recordCount: number, + warningCount: number, + model: string | null, + reason: PipelineStageAudit['reason'], + durationMs: number, + responses: LlmResponseMetadata[], + config: T2CConfig, + options: CommunicationExtractionOptions, +): PipelineStageAudit { + return { + runtimeVersion: T2C_VERSION, + configuration: { + ...openRouterAuditConfiguration(config, model), + projectDirectory: options.projectDir ?? 'project', + ticket: options.ticket ?? null, + }, + status, requestedMode, effectiveMode, degraded, recordCount, warningCount, + model, durationMs, reason, responses, + }; +} + +export async function readCommunicationPrompt(): Promise { + const promptPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../prompts', 'communication-to-intent.system.md'); + if (!(await pathExists(promptPath))) throw new Error(`Prompt not found: ${promptPath}`); + return fs.readFile(promptPath, 'utf8'); +} + +function roleOf(record: IntentRecord | undefined): CommunicationRole { + return record?.metadata.participantRole === 'human' || record?.metadata.participantRole === 'agent' + ? record.metadata.participantRole + : 'unknown'; +} + +function sortedUnique(values: string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort(); +} diff --git a/src/communication/communication-llm-schema.ts b/src/communication/communication-llm-schema.ts new file mode 100644 index 00000000..0b95e5c9 --- /dev/null +++ b/src/communication/communication-llm-schema.ts @@ -0,0 +1,39 @@ +import type { IntentAction } from '../core/types.js'; +import { structuredSchema as s, type StructuredSchema } from '../llm/structured-schema.js'; +import type { + RawCommunicationEnrichment, + RawCommunicationResponse, + RawParticipantSynthesis, +} from './communication-llm-types.js'; + +const ACTIONS = [ + 'add', 'fix', 'remove', 'refactor', 'test', 'document', 'configure', 'analyze', 'validate', + 'call', 'depend_on', 'declare', 'release', 'change', 'preserve', 'block', 'approve', 'unknown', +] as const satisfies readonly IntentAction[]; + +const communicationStrings = () => s.array(s.string()); + +const COMMUNICATION_ENRICHMENT_CONTRACT = s.object({ + recordId: s.string(), + action: s.enum(ACTIONS), + object: s.string(), + polarity: s.enum(['positive', 'negative']), + confidence: s.number({ minimum: 0, maximum: 0.85 }), + basis: communicationStrings(), + target: s.object({ paths: communicationStrings(), symbols: communicationStrings(), versions: communicationStrings() }), + topics: communicationStrings(), +}) satisfies StructuredSchema; + +const PARTICIPANT_SYNTHESIS_CONTRACT = s.object({ + participantKey: s.string(), + summary: s.string({ minLength: 1, pattern: '.*\\S.*' }), + commitments: communicationStrings(), + risks: communicationStrings(), + recordIds: communicationStrings(), + confidence: s.number({ minimum: 0, maximum: 0.85 }), +}) satisfies StructuredSchema; + +export const COMMUNICATION_RESPONSE_CONTRACT = s.object({ + enrichments: s.array(COMMUNICATION_ENRICHMENT_CONTRACT), + participantSyntheses: s.array(PARTICIPANT_SYNTHESIS_CONTRACT), +}) satisfies StructuredSchema; diff --git a/src/communication/communication-llm-types.ts b/src/communication/communication-llm-types.ts new file mode 100644 index 00000000..a6ba280b --- /dev/null +++ b/src/communication/communication-llm-types.ts @@ -0,0 +1,69 @@ +import type { + GroundedGenerationMetadata, + IntentAction, + IntentRecord, + PipelineStageAudit, +} from '../core/types.js'; +import type { CommunicationRole } from '../extractors/communication.js'; + +export interface RawCommunicationEnrichment { + recordId: string; + action: IntentAction; + object: string; + polarity: 'positive' | 'negative'; + confidence: number; + basis: string[]; + target: { paths: string[]; symbols: string[]; versions: string[] }; + topics: string[]; +} + +export interface RawParticipantSynthesis { + participantKey: string; + summary: string; + commitments: string[]; + risks: string[]; + recordIds: string[]; + confidence: number; +} + +export interface RawCommunicationResponse { + enrichments: RawCommunicationEnrichment[]; + participantSyntheses: RawParticipantSynthesis[]; +} + +export interface ParticipantCommunicationSynthesis { + schemaVersion: 't2c.participant-synthesis/v1'; + id: string; + participant: string; + role: CommunicationRole; + tickets: string[]; + summary: string; + commitments: string[]; + risks: string[]; + recordIds: string[]; + confidence: number; + generation: GroundedGenerationMetadata; +} + +export interface AuditedCommunicationExtractionResult { + schemaVersion: 't2c.communication-enrichment/v1'; + records: IntentRecord[]; + participants: ParticipantCommunicationSynthesis[]; + warnings: string[]; + audit: PipelineStageAudit; +} + +export class CommunicationLlmRequiredError extends Error { + constructor(message: string, readonly audit: PipelineStageAudit) { + super(message); + this.name = 'CommunicationLlmRequiredError'; + } +} + +export interface ParticipantGroup { + key: string; + participant: string; + role: CommunicationRole; + tickets: string[]; + records: IntentRecord[]; +} diff --git a/src/communication/identity.ts b/src/communication/identity.ts index 15f36fe9..7075dd36 100644 --- a/src/communication/identity.ts +++ b/src/communication/identity.ts @@ -103,40 +103,56 @@ export function assertParticipantIdentityRegistry(value: unknown): asserts value const ids = new Set(); const external = new Map(); for (const raw of registry.participants) { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Participant registry entry must be an object'); - const entry = raw as Record; - exactKeys(entry, ['id', 'role', 'displayName', 'gitAuthors', 'a2aAgentIds', 'humanAliases'], 'Participant registry entry'); - if (typeof entry.id !== 'string' || !/^(human|agent):[a-z0-9][a-z0-9._-]*$/.test(entry.id)) { - throw new Error('Participant registry id must be canonical human: or agent:'); - } - if (entry.role !== 'human' && entry.role !== 'agent') throw new Error(`Participant ${entry.id} role must be human or agent`); - if (!entry.id.startsWith(`${entry.role}:`)) throw new Error(`Participant ${entry.id} role does not match its stable ID prefix`); - if (typeof entry.displayName !== 'string' || !entry.displayName.trim()) throw new Error(`Participant ${entry.id} displayName must be non-blank`); - if (ids.has(entry.id)) throw new Error(`Duplicate participant registry id: ${entry.id}`); - ids.add(entry.id); - for (const field of ['gitAuthors', 'a2aAgentIds', 'humanAliases'] as const) { - const values = entry[field]; - if (!Array.isArray(values) || values.some((item) => typeof item !== 'string' || !item.trim())) { - throw new Error(`Participant ${entry.id} ${field} must contain non-blank strings`); - } - const normalized = values.map((item) => (item as string).trim().toLowerCase()); - if (new Set(normalized).size !== normalized.length) throw new Error(`Participant ${entry.id} ${field} must be unique`); - for (const identifier of normalized) { - const key = `${field}:${identifier}`; - const owner = external.get(key); - if (owner && owner !== entry.id) throw new Error(`${field} identifier ${identifier} is assigned to both ${owner} and ${entry.id}`); - external.set(key, entry.id); - } - } - if (entry.role === 'human' && (entry.a2aAgentIds as unknown[]).length) { - throw new Error(`Human participant ${entry.id} cannot declare a2aAgentIds`); + validateIdentityEntry(raw, ids, external); + } +} + +function validateIdentityEntry( + raw: unknown, + ids: Set, + external: Map, +): void { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Participant registry entry must be an object'); + const entry = raw as Record; + exactKeys(entry, ['id', 'role', 'displayName', 'gitAuthors', 'a2aAgentIds', 'humanAliases'], 'Participant registry entry'); + if (typeof entry.id !== 'string' || !/^(human|agent):[a-z0-9][a-z0-9._-]*$/.test(entry.id)) { + throw new Error('Participant registry id must be canonical human: or agent:'); + } + if (entry.role !== 'human' && entry.role !== 'agent') throw new Error(`Participant ${entry.id} role must be human or agent`); + if (!entry.id.startsWith(`${entry.role}:`)) throw new Error(`Participant ${entry.id} role does not match its stable ID prefix`); + if (typeof entry.displayName !== 'string' || !entry.displayName.trim()) throw new Error(`Participant ${entry.id} displayName must be non-blank`); + if (ids.has(entry.id)) throw new Error(`Duplicate participant registry id: ${entry.id}`); + ids.add(entry.id); + validateExternalIdentifiers(entry, external); + validateRoleIdentifiers(entry); +} + +function validateExternalIdentifiers(entry: Record, external: Map): void { + for (const field of ['gitAuthors', 'a2aAgentIds', 'humanAliases'] as const) { + const values = entry[field]; + if (!Array.isArray(values) || values.some((item) => typeof item !== 'string' || !item.trim())) { + throw new Error(`Participant ${entry.id} ${field} must contain non-blank strings`); } - if (entry.role === 'agent' && (entry.humanAliases as unknown[]).length) { - throw new Error(`Agent participant ${entry.id} cannot declare humanAliases`); + const normalized = values.map((item) => (item as string).trim().toLowerCase()); + if (new Set(normalized).size !== normalized.length) throw new Error(`Participant ${entry.id} ${field} must be unique`); + for (const identifier of normalized) { + const key = `${field}:${identifier}`; + const owner = external.get(key); + if (owner && owner !== entry.id) throw new Error(`${field} identifier ${identifier} is assigned to both ${owner} and ${entry.id}`); + external.set(key, entry.id as string); } } } +function validateRoleIdentifiers(entry: Record): void { + if (entry.role === 'human' && (entry.a2aAgentIds as unknown[]).length) { + throw new Error(`Human participant ${entry.id} cannot declare a2aAgentIds`); + } + if (entry.role === 'agent' && (entry.humanAliases as unknown[]).length) { + throw new Error(`Agent participant ${entry.id} cannot declare humanAliases`); + } +} + function exactKeys(value: Record, expected: string[], label: string): void { const allowed = new Set(expected); const missing = expected.filter((key) => !(key in value)); diff --git a/src/communication/intake-protobuf.ts b/src/communication/intake-protobuf.ts index 8e6c2383..a0113654 100644 --- a/src/communication/intake-protobuf.ts +++ b/src/communication/intake-protobuf.ts @@ -74,32 +74,78 @@ export function encodeIntakeResult(result: IntakeResult): Uint8Array { export function decodeIntakeResult(bytes: Uint8Array): IntakeResult { try { - const strings = new Map(); - const numbers = new Map(); - let offset = 0; - while (offset < bytes.length) { - const [tag, afterTag] = readVarint(bytes, offset); offset = afterTag; - const field = tag >>> 3; const wire = tag & 7; - if (wire === 0) { const [value, after] = readVarint(bytes, offset); offset = after; numbers.set(field, value); } - else if (wire === 2) { - const [length, afterLength] = readVarint(bytes, offset); offset = afterLength; - if (offset + length > bytes.length) throw new Error('Truncated result field'); - strings.set(field, Buffer.from(bytes.slice(offset, offset + length)).toString('utf8')); offset += length; - } else throw new Error(`Unsupported result wire type ${wire}`); - } - return { - schemaVersion: strings.get(1) as IntakeResult['schemaVersion'], accepted: numbers.get(2) === 1, - messageId: strings.get(3) ?? '', correlationId: strings.get(4) ?? '', causationId: strings.get(5) ?? null, - authenticatedPrincipal: strings.get(6) ?? '', aggregateId: 'intake', expectedVersion: numbers.get(8) ?? null, - actualVersion: numbers.get(9) ?? 0, idempotencyKey: strings.get(10) ?? '', timestamp: strings.get(11) ?? '', - payloadHash: strings.get(12) ?? '', diagnostic: strings.has(13) ? JSON.parse(strings.get(13) as string) : null, - data: strings.has(14) ? JSON.parse(strings.get(14) as string) : null, - }; + const fields = decodeResultFields(bytes); + return intakeResultFromFields(fields.strings, fields.numbers); } catch (error) { throw new IntakeError('T2C-INTAKE-INVALID-WIRE', error instanceof Error ? error.message : String(error), 'Send a valid t2c.intake-result/v1 Protobuf payload.'); } } +function decodeResultFields(bytes: Uint8Array): { + strings: Map; + numbers: Map; +} { + const strings = new Map(); + const numbers = new Map(); + let offset = 0; + while (offset < bytes.length) { + const [tag, afterTag] = readVarint(bytes, offset); offset = afterTag; + const field = tag >>> 3; const wire = tag & 7; + if (wire === 0) { + const [value, after] = readVarint(bytes, offset); offset = after; numbers.set(field, value); + continue; + } + if (wire !== 2) throw new Error(`Unsupported result wire type ${wire}`); + const [length, afterLength] = readVarint(bytes, offset); offset = afterLength; + if (offset + length > bytes.length) throw new Error('Truncated result field'); + strings.set(field, Buffer.from(bytes.slice(offset, offset + length)).toString('utf8')); + offset += length; + } + return { strings, numbers }; +} + +function resultMessageFields(strings: Map): { + messageId: string; + correlationId: string; + causationId: string | null; + authenticatedPrincipal: string; +} { + return { + messageId: strings.get(3) ?? '', + correlationId: strings.get(4) ?? '', + causationId: strings.get(5) ?? null, + authenticatedPrincipal: strings.get(6) ?? '', + }; +} + +function resultVersionFields(strings: Map, numbers: Map): { + expectedVersion: number | null; + actualVersion: number; + idempotencyKey: string; + timestamp: string; + payloadHash: string; +} { + return { + expectedVersion: numbers.get(8) ?? null, + actualVersion: numbers.get(9) ?? 0, + idempotencyKey: strings.get(10) ?? '', + timestamp: strings.get(11) ?? '', + payloadHash: strings.get(12) ?? '', + }; +} + +function intakeResultFromFields(strings: Map, numbers: Map): IntakeResult { + return { + schemaVersion: strings.get(1) as IntakeResult['schemaVersion'], + accepted: numbers.get(2) === 1, + ...resultMessageFields(strings), + ...resultVersionFields(strings, numbers), + aggregateId: 'intake', + diagnostic: strings.has(13) ? JSON.parse(strings.get(13) as string) : null, + data: strings.has(14) ? JSON.parse(strings.get(14) as string) : null, + }; +} + function bytesField(field: number, value: string): Uint8Array { const data = Buffer.from(value, 'utf8'); return Buffer.concat([writeVarint((field << 3) | 2), writeVarint(data.length), data]); diff --git a/src/communication/intake-service.ts b/src/communication/intake-service.ts index ce290b60..74ec8220 100644 --- a/src/communication/intake-service.ts +++ b/src/communication/intake-service.ts @@ -269,17 +269,48 @@ function ensurePrincipalAvailable(state: IntakeState, principal: ParticipantV2[' const conflict = [...state.participants.values()].find((entry) => entry.id !== owner && entry.principals.some((item) => principalKey(item) === key)); if (conflict) throw new IntakeError('T2C-INTAKE-UNVERIFIED-ACTOR', `Principal is already bound to ${conflict.id}`, 'Use a unique verified principal.'); } +const SECRET_ASSIGNMENT_PATTERN = String.raw`(?:api[_-]?key|token|password|secret)\s*[:=]\s*[^\s]{8,}`; +const PEM_KEY_HEADER_PATTERN = ['-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE ', 'KEY-----'].join(''); +const SECRET_INPUT_PATTERN = new RegExp(`${SECRET_ASSIGNMENT_PATTERN}|${PEM_KEY_HEADER_PATTERN}`, 'i'); function rejectSecrets(message: string): void { - if (/(?:api[_-]?key|token|password|secret)\s*[:=]\s*[^\s]{8,}|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i.test(message)) { + if (SECRET_INPUT_PATTERN.test(message)) { throw new IntakeError('T2C-INTAKE-SECRET-INPUT', 'Message resembles a secret and was not persisted', 'Remove or redact the secret before resubmitting.'); } } function accepted(envelope: IntakeEnvelope, version: number, data: unknown): IntakeResult { return { schemaVersion: RESULT_SCHEMA_VERSION, accepted: true, messageId: envelope.messageId, correlationId: envelope.correlationId, causationId: envelope.causationId, authenticatedPrincipal: envelope.authenticatedPrincipal, aggregateId: 'intake', expectedVersion: envelope.expectedVersion, actualVersion: version, idempotencyKey: envelope.idempotencyKey, timestamp: new Date().toISOString(), payloadHash: envelope.payloadHash, diagnostic: null, data }; } +function rejectedEnvelopeFields(envelope: Partial): { + messageId: string; + correlationId: string; + causationId: string | null; + authenticatedPrincipal: string; + expectedVersion: number | null; + idempotencyKey: string; + payloadHash: string; +} { + return { + messageId: envelope.messageId ?? '', + correlationId: envelope.correlationId ?? '', + causationId: envelope.causationId ?? null, + authenticatedPrincipal: envelope.authenticatedPrincipal ?? '', + expectedVersion: envelope.expectedVersion ?? null, + idempotencyKey: envelope.idempotencyKey ?? '', + payloadHash: envelope.payloadHash ?? '', + }; +} function rejected(input: unknown, error: unknown, version: number): IntakeResult { const envelope = input && typeof input === 'object' ? input as Partial : {}; - return { schemaVersion: RESULT_SCHEMA_VERSION, accepted: false, messageId: envelope.messageId ?? '', correlationId: envelope.correlationId ?? '', causationId: envelope.causationId ?? null, authenticatedPrincipal: envelope.authenticatedPrincipal ?? '', aggregateId: 'intake', expectedVersion: envelope.expectedVersion ?? null, actualVersion: version, idempotencyKey: envelope.idempotencyKey ?? '', timestamp: new Date().toISOString(), payloadHash: envelope.payloadHash ?? '', diagnostic: diagnostic(error), data: null }; + return { + schemaVersion: RESULT_SCHEMA_VERSION, + accepted: false, + ...rejectedEnvelopeFields(envelope), + aggregateId: 'intake', + actualVersion: version, + timestamp: new Date().toISOString(), + diagnostic: diagnostic(error), + data: null, + }; } function unauthorized(message: string): IntakeError { return new IntakeError('T2C-INTAKE-UNAUTHORIZED', message, 'Use a verified principal with the explicitly granted capability.'); } function duplicate(message: string): IntakeError { return new IntakeError('T2C-INTAKE-DUPLICATE', message, 'Resolve or query the existing aggregate instead.'); } diff --git a/src/communication/llm.ts b/src/communication/llm.ts index 83cb144d..632871e5 100644 --- a/src/communication/llm.ts +++ b/src/communication/llm.ts @@ -1,101 +1,49 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { T2CConfig } from '../config/env.js'; -import { createIntentId, sha256, stableStringify } from '../core/id.js'; -import { pathExists } from '../core/io.js'; -import { buildRecord, withRecordGeneration } from '../core/record.js'; -import type { - GroundedGenerationMetadata, - IntentAction, - IntentRecord, - LlmExtractionMode, - LlmResponseMetadata, - PipelineStageAudit, -} from '../core/types.js'; -import { classifyLlmFailure, rejectedLlmResponseMetadata, type LlmFailureReason } from '../llm/failure.js'; -import { openRouterAuditConfiguration } from '../llm/audit.js'; -import { OpenRouterClient, type OpenRouterResult } from '../llm/openrouter.js'; -import { StructuredResponseError, structuredSchema as s, type StructuredSchema } from '../llm/structured-schema.js'; -import { T2C_VERSION } from '../version.js'; +import type { LlmExtractionMode } from '../core/types.js'; +import { classifyLlmFailure, rejectedLlmResponseMetadata } from '../llm/failure.js'; +import { OpenRouterClient } from '../llm/openrouter.js'; import { extractCommunicationIntent, type CommunicationExtractionOptions, - type CommunicationRole, } from '../extractors/communication.js'; - -const ACTIONS = [ - 'add', 'fix', 'remove', 'refactor', 'test', 'document', 'configure', 'analyze', 'validate', - 'call', 'depend_on', 'declare', 'release', 'change', 'preserve', 'block', 'approve', 'unknown', -] as const satisfies readonly IntentAction[]; - -interface RawCommunicationEnrichment { - recordId: string; - action: IntentAction; - object: string; - polarity: 'positive' | 'negative'; - confidence: number; - basis: string[]; - target: { paths: string[]; symbols: string[]; versions: string[] }; - topics: string[]; -} - -interface RawParticipantSynthesis { - participantKey: string; - summary: string; - commitments: string[]; - risks: string[]; - recordIds: string[]; - confidence: number; -} - -interface RawCommunicationResponse { - enrichments: RawCommunicationEnrichment[]; - participantSyntheses: RawParticipantSynthesis[]; -} - -export interface ParticipantCommunicationSynthesis { - schemaVersion: 't2c.participant-synthesis/v1'; - id: string; - participant: string; - role: CommunicationRole; - tickets: string[]; - summary: string; - commitments: string[]; - risks: string[]; - recordIds: string[]; - confidence: number; - generation: GroundedGenerationMetadata; -} - -export interface AuditedCommunicationExtractionResult { - schemaVersion: 't2c.communication-enrichment/v1'; - records: IntentRecord[]; - participants: ParticipantCommunicationSynthesis[]; - warnings: string[]; - audit: PipelineStageAudit; -} - -export class CommunicationLlmRequiredError extends Error { - constructor(message: string, readonly audit: PipelineStageAudit) { - super(message); - this.name = 'CommunicationLlmRequiredError'; - } -} +import { + CommunicationAttemptError, + enrichWithCorrection, + fallbackOrThrow, +} from './communication-llm-enrich.js'; +import { + communicationAudit, + deterministicGeneration, + deterministicSyntheses, + enrichRecord, + llmGeneration, + markDeterministic, + materializeSyntheses, + participantGroups, + promptPayload, + readCommunicationPrompt, + validateEnrichments, +} from './communication-llm-helpers.js'; + +export type { + AuditedCommunicationExtractionResult, + ParticipantCommunicationSynthesis, +} from './communication-llm-types.js'; +export { CommunicationLlmRequiredError } from './communication-llm-types.js'; export async function extractCommunicationIntentAudited( options: CommunicationExtractionOptions, config: T2CConfig, mode: LlmExtractionMode = config.communicationMode, -): Promise { +) { const startedAt = Date.now(); const deterministic = await extractCommunicationIntent(options, config); if (deterministic.records.length === 0) { return { - schemaVersion: 't2c.communication-enrichment/v1', + schemaVersion: 't2c.communication-enrichment/v1' as const, ...deterministic, participants: [], - audit: audit('skipped', mode === 'deterministic' ? 'deterministic' : 'llm', 'none', false, + audit: communicationAudit('skipped', mode === 'deterministic' ? 'deterministic' : 'llm', 'none', false, deterministic.records.length, deterministic.warnings.length, null, { code: 'NO_COMMUNICATION_RECORDS', message: 'No communication records were available for enrichment' }, Date.now() - startedAt, [], config, options), @@ -104,10 +52,11 @@ export async function extractCommunicationIntentAudited( if (mode === 'deterministic') { const records = markDeterministic(deterministic.records, false, null); return { - schemaVersion: 't2c.communication-enrichment/v1', records, + schemaVersion: 't2c.communication-enrichment/v1' as const, + records, participants: deterministicSyntheses(records, deterministicGeneration()), warnings: deterministic.warnings, - audit: audit(deterministic.warnings.length ? 'partial' : 'succeeded', 'deterministic', 'deterministic', false, records.length, + audit: communicationAudit(deterministic.warnings.length ? 'partial' : 'succeeded', 'deterministic', 'deterministic', false, records.length, deterministic.warnings.length, null, null, Date.now() - startedAt, [], config, options), }; } @@ -121,12 +70,12 @@ export async function extractCommunicationIntentAudited( try { const groups = participantGroups(deterministic.records); const { completion, responses } = await enrichWithCorrection(client, [ - { role: 'system', content: await readPrompt() }, + { role: 'system', content: await readCommunicationPrompt() }, { role: 'user', content: JSON.stringify(promptPayload(deterministic.records, groups)) }, ], config.openRouter.communicationModel); const response = completion.value; const enrichments = validateEnrichments(response.enrichments, deterministic.records); - const enrichedByOriginal = new Map(); + const enrichedByOriginal = new Map(); for (const record of deterministic.records) { enrichedByOriginal.set(record.id, enrichRecord(record, enrichments.get(record.id)!, config, completion.metadata)); } @@ -138,11 +87,11 @@ export async function extractCommunicationIntentAudited( generation, ); return { - schemaVersion: 't2c.communication-enrichment/v1', + schemaVersion: 't2c.communication-enrichment/v1' as const, records: [...enrichedByOriginal.values()], participants, warnings: deterministic.warnings, - audit: audit(deterministic.warnings.length ? 'partial' : 'succeeded', 'llm', 'llm', false, deterministic.records.length, + audit: communicationAudit(deterministic.warnings.length ? 'partial' : 'succeeded', 'llm', 'llm', false, deterministic.records.length, deterministic.warnings.length, config.openRouter.communicationModel, null, Date.now() - startedAt, responses, config, options), }; @@ -157,358 +106,3 @@ export async function extractCommunicationIntentAudited( ); } } - -class CommunicationAttemptError extends Error { - constructor(readonly failure: unknown, readonly responses: LlmResponseMetadata[]) { - super(failure instanceof Error ? failure.message : String(failure)); - this.name = 'CommunicationAttemptError'; - } -} - -async function enrichWithCorrection( - client: OpenRouterClient, - baseMessages: Array<{ role: 'system' | 'user'; content: string }>, - model: string, -): Promise<{ completion: OpenRouterResult; responses: LlmResponseMetadata[] }> { - const responses: LlmResponseMetadata[] = []; - let correction: string | null = null; - - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - const completion = await client.chatStructuredWithMetadata([ - ...baseMessages, - ...(correction - ? [{ - role: 'user' as const, - content: `The previous response was rejected: ${correction}\n` - + 'Correct exactly that violation and re-emit the full object. Do not add, rename, or omit properties.\n' - + `The exact required JSON Schema is: ${JSON.stringify(COMMUNICATION_RESPONSE_CONTRACT.jsonSchema)}`, - }] - : []), - ], 't2c_communication_enrichment', COMMUNICATION_RESPONSE_CONTRACT, model); - responses.push(completion.metadata); - return { completion, responses }; - } catch (error) { - if (error instanceof StructuredResponseError) { - if (error.responseMetadata) responses.push(error.responseMetadata); - if (attempt === 0) { - correction = error.message; - continue; - } - } - throw new CommunicationAttemptError(error, [...responses]); - } - } - - throw new CommunicationAttemptError(new Error('Communication correction retry budget exhausted'), responses); -} - -async function fallbackOrThrow( - records: IntentRecord[], - warnings: string[], - config: T2CConfig, - options: CommunicationExtractionOptions, - mode: LlmExtractionMode, - startedAt: number, - reason: LlmFailureReason, - responses: LlmResponseMetadata[] = [], -): Promise { - const failed = audit('failed', 'llm', 'none', true, 0, warnings.length + 1, - config.openRouter.communicationModel, reason, Date.now() - startedAt, responses, config, options); - if (mode === 'require-llm') { - throw new CommunicationLlmRequiredError(`Communication enrichment requires LLM: ${reason.message}`, failed); - } - const warning = `Communication enrichment used deterministic fallback (${reason.code}): ${reason.message}`; - const marked = markDeterministic(records, true, reason.code); - return { - schemaVersion: 't2c.communication-enrichment/v1', - records: marked, - participants: deterministicSyntheses(marked, fallbackGeneration(reason.code)), - warnings: [...warnings, warning], - audit: audit('fallback', 'llm', 'deterministic', true, marked.length, warnings.length + 1, - config.openRouter.communicationModel, reason, Date.now() - startedAt, responses, config, options), - }; -} - -interface ParticipantGroup { - key: string; - participant: string; - role: CommunicationRole; - tickets: string[]; - records: IntentRecord[]; -} - -function participantGroups(records: IntentRecord[]): ParticipantGroup[] { - const grouped = new Map(); - for (const record of records) { - const participant = String(record.metadata.participant ?? record.statement.actor ?? `unknown:${record.id}`); - const role = roleOf(record); - const key = stableStringify({ participant, role }); - const values = grouped.get(key); - if (values) values.push(record); - else grouped.set(key, [record]); - } - return [...grouped.values()].map((values, index) => ({ - key: `participant-${index + 1}`, - participant: String(values[0]?.metadata.participant ?? values[0]?.statement.actor ?? 'unknown'), - role: roleOf(values[0]), - tickets: [...new Set(values.flatMap((record) => record.statement.target.tickets))].sort(), - records: [...values].sort((left, right) => left.id.localeCompare(right.id)), - })).sort((left, right) => left.role.localeCompare(right.role) || left.participant.localeCompare(right.participant)) - .map((group, index) => ({ ...group, key: `participant-${index + 1}` })); -} - -function promptPayload(records: IntentRecord[], groups: ParticipantGroup[]): Record { - return { - records: records.map((record) => ({ - recordId: record.id, - participantKey: groups.find((group) => group.records.some((item) => item.id === record.id))?.key, - text: record.statement.text, - deterministic: { - action: record.statement.action, - object: record.statement.object, - polarity: record.statement.polarity, - paths: record.statement.target.paths, - symbols: record.statement.target.symbols, - versions: record.statement.target.versions, - }, - })), - participants: groups.map((group) => ({ - participantKey: group.key, - recordIds: group.records.map((record) => record.id), - })), - }; -} - -function validateEnrichments(values: RawCommunicationEnrichment[] | undefined, records: IntentRecord[]): Map { - if (!Array.isArray(values)) throw new Error('Structured response does not contain communication enrichments'); - const expected = new Set(records.map((record) => record.id)); - const output = new Map(); - for (const value of values) { - if (!expected.has(value.recordId)) throw new Error(`Structured response contains unknown recordId: ${value.recordId}`); - if (output.has(value.recordId)) throw new Error(`Structured response duplicates recordId: ${value.recordId}`); - output.set(value.recordId, value); - } - if (output.size !== expected.size) throw new Error(`Structured response returned ${output.size} of ${expected.size} enrichments`); - return output; -} - -function materializeSyntheses( - values: RawParticipantSynthesis[] | undefined, - groups: ParticipantGroup[], - enrichedByOriginal: Map, - generation: GroundedGenerationMetadata, -): ParticipantCommunicationSynthesis[] { - if (!Array.isArray(values)) throw new Error('Structured response does not contain participantSyntheses'); - const byKey = new Map(groups.map((group) => [group.key, group])); - const seen = new Set(); - const output = values.map((raw) => { - const group = byKey.get(raw.participantKey); - if (!group) throw new Error(`Structured response contains unknown participantKey: ${raw.participantKey}`); - if (seen.has(raw.participantKey)) throw new Error(`Structured response duplicates participantKey: ${raw.participantKey}`); - seen.add(raw.participantKey); - const permitted = new Set(group.records.map((record) => record.id)); - if (!raw.recordIds.length || raw.recordIds.some((id) => !permitted.has(id))) { - throw new Error(`Participant synthesis ${raw.participantKey} contains ungrounded recordIds`); - } - const recordIds = raw.recordIds.map((id) => enrichedByOriginal.get(id)?.id) - .filter((id): id is string => Boolean(id)).sort(); - return synthesis({ - participant: group.participant, - role: group.role, - tickets: group.tickets, - summary: raw.summary, - commitments: raw.commitments, - risks: raw.risks, - recordIds, - confidence: raw.confidence, - generation, - }); - }); - if (seen.size !== groups.length) throw new Error(`Structured response returned ${seen.size} of ${groups.length} participant syntheses`); - return output.sort((left, right) => left.role.localeCompare(right.role) || left.participant.localeCompare(right.participant)); -} - -function enrichRecord( - record: IntentRecord, - enrichment: RawCommunicationEnrichment, - config: T2CConfig, - response: LlmResponseMetadata, -): IntentRecord { - return buildRecord({ - kind: record.statement.kind, - actor: record.statement.actor, - action: enrichment.action, - subject: record.statement.subject, - object: enrichment.object.trim() || record.statement.object, - target: { - paths: [...record.statement.target.paths, ...enrichment.target.paths], - symbols: [...record.statement.target.symbols, ...enrichment.target.symbols], - // Ticket ownership is structural and never accepted from the model. - tickets: record.statement.target.tickets, - versions: [...record.statement.target.versions, ...enrichment.target.versions], - }, - modality: record.statement.modality, - polarity: enrichment.polarity, - text: record.statement.text, - lifecycle: record.lifecycle.status, - sourceKind: record.source.kind, - sourcePath: record.source.path, - sourceLines: record.source.lines, - revision: record.source.revision, - symbol: record.source.symbol, - commitIndex: record.source.commitIndex, - extractor: 't2c/project-communication-openrouter@1', - rawExcerpt: record.source.rawExcerpt, - // A plan/declaration/claim cannot become a fact through model prose. - epistemicClass: record.epistemic.class, - confidence: Math.min(0.85, Math.max(0.05, enrichment.confidence)), - basis: [...record.epistemic.basis, 'openrouter_communication_enrichment', ...enrichment.basis], - observedAt: record.observedAt, - generation: { - requested: 'llm', used: 'llm', provider: response.provider ?? 'openrouter', - model: response.model ?? config.openRouter.communicationModel, responseId: response.responseId, - }, - metadata: { - ...record.metadata, - llmUsed: true, - topics: sortedUnique(enrichment.topics), - response, - }, - }); -} - -function deterministicSyntheses(records: IntentRecord[], generation: GroundedGenerationMetadata): ParticipantCommunicationSynthesis[] { - return participantGroups(records).map((group) => synthesis({ - participant: group.participant, - role: group.role, - tickets: group.tickets, - summary: `${group.participant} (${group.role}) ma ${group.records.length} uziemionych rekordów komunikacji.`, - commitments: group.records.filter((record) => record.epistemic.class === 'plan') - .map((record) => record.statement.text), - risks: group.records.filter((record) => record.statement.polarity === 'negative') - .map((record) => record.statement.text), - recordIds: group.records.map((record) => record.id).sort(), - confidence: 1, - generation, - })); -} - -function synthesis(input: Omit): ParticipantCommunicationSynthesis { - const semantic = { - participant: input.participant, - role: input.role, - tickets: sortedUnique(input.tickets), - summary: input.summary.trim(), - commitments: sortedUnique(input.commitments), - risks: sortedUnique(input.risks), - recordIds: sortedUnique(input.recordIds), - }; - if (!semantic.summary || !semantic.recordIds.length) throw new Error('Participant synthesis requires a summary and record citations'); - return { - schemaVersion: 't2c.participant-synthesis/v1', - id: createIntentId(semantic, 'COMM-SYN'), - ...semantic, - confidence: Math.min(1, Math.max(0, input.confidence)), - generation: input.generation, - }; -} - -function markDeterministic(records: IntentRecord[], degraded: boolean, fallbackReason: string | null): IntentRecord[] { - return records.map((record) => { - const marked = withRecordGeneration(record, { - requested: degraded ? 'llm' : 'deterministic', used: 'deterministic', degraded, fallbackReason, - }); - return { ...marked, metadata: { ...marked.metadata, llmUsed: false } }; - }); -} - -function deterministicGeneration(): GroundedGenerationMetadata { - return { - generator: 't2c/participant-synthesis', generatorVersion: '1', - runtimeVersion: T2C_VERSION, generatedAt: new Date().toISOString(), - requestedMode: 'deterministic', effectiveMode: 'deterministic', degraded: false, - model: null, provider: null, responseId: null, - configurationFingerprint: sha256('t2c-communication-deterministic/v1'), reason: null, - }; -} - -function fallbackGeneration(reason: string): GroundedGenerationMetadata { - return { - generator: 't2c/participant-synthesis', generatorVersion: '1', - runtimeVersion: T2C_VERSION, generatedAt: new Date().toISOString(), - requestedMode: 'prefer-llm', effectiveMode: 'deterministic', degraded: true, - model: null, provider: null, responseId: null, - configurationFingerprint: sha256(stableStringify({ stage: 'communication', reason })), reason, - }; -} - -function llmGeneration(config: T2CConfig, mode: LlmExtractionMode, response: LlmResponseMetadata): GroundedGenerationMetadata { - return { - generator: 't2c/participant-synthesis', generatorVersion: '1', - runtimeVersion: T2C_VERSION, generatedAt: new Date().toISOString(), - requestedMode: mode, effectiveMode: 'llm', degraded: false, - model: response.model ?? config.openRouter.communicationModel, - provider: response.provider ?? 'openrouter', responseId: response.responseId, - configurationFingerprint: sha256(stableStringify(openRouterAuditConfiguration(config, config.openRouter.communicationModel))), - reason: null, - }; -} - -function audit( - status: PipelineStageAudit['status'], requestedMode: PipelineStageAudit['requestedMode'], - effectiveMode: PipelineStageAudit['effectiveMode'], degraded: boolean, recordCount: number, - warningCount: number, model: string | null, reason: PipelineStageAudit['reason'], durationMs: number, - responses: LlmResponseMetadata[], config: T2CConfig, options: CommunicationExtractionOptions, -): PipelineStageAudit { - return { - runtimeVersion: T2C_VERSION, - configuration: { - ...openRouterAuditConfiguration(config, model), - projectDirectory: options.projectDir ?? 'project', - ticket: options.ticket ?? null, - }, - status, requestedMode, effectiveMode, degraded, recordCount, warningCount, - model, durationMs, reason, responses, - }; -} - -function roleOf(record: IntentRecord | undefined): CommunicationRole { - return record?.metadata.participantRole === 'human' || record?.metadata.participantRole === 'agent' - ? record.metadata.participantRole - : 'unknown'; -} - -function sortedUnique(values: string[]): string[] { - return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort(); -} - -async function readPrompt(): Promise { - const promptPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../prompts', 'communication-to-intent.system.md'); - if (!(await pathExists(promptPath))) throw new Error(`Prompt not found: ${promptPath}`); - return fs.readFile(promptPath, 'utf8'); -} - -const communicationStrings = () => s.array(s.string()); -const COMMUNICATION_ENRICHMENT_CONTRACT = s.object({ - recordId: s.string(), - action: s.enum(ACTIONS), - object: s.string(), - polarity: s.enum(['positive', 'negative']), - confidence: s.number({ minimum: 0, maximum: 0.85 }), - basis: communicationStrings(), - target: s.object({ paths: communicationStrings(), symbols: communicationStrings(), versions: communicationStrings() }), - topics: communicationStrings(), -}) satisfies StructuredSchema; -const PARTICIPANT_SYNTHESIS_CONTRACT = s.object({ - participantKey: s.string(), - summary: s.string({ minLength: 1, pattern: '.*\\S.*' }), - commitments: communicationStrings(), - risks: communicationStrings(), - recordIds: communicationStrings(), - confidence: s.number({ minimum: 0, maximum: 0.85 }), -}) satisfies StructuredSchema; -const COMMUNICATION_RESPONSE_CONTRACT = s.object({ - enrichments: s.array(COMMUNICATION_ENRICHMENT_CONTRACT), - participantSyntheses: s.array(PARTICIPANT_SYNTHESIS_CONTRACT), -}) satisfies StructuredSchema; diff --git a/src/core/ignore.ts b/src/core/ignore.ts index fbc30b3b..2b7ac2f4 100644 --- a/src/core/ignore.ts +++ b/src/core/ignore.ts @@ -79,28 +79,15 @@ function translateGlob(pattern: string): string { for (let index = 0; index < pattern.length; index += 1) { const char = pattern[index] ?? ''; if (char === '\\') { - const next = pattern[index + 1]; - if (next !== undefined) { - output += escapeLiteral(next); - index += 1; - continue; - } - output += '\\\\'; + const translated = translateEscape(pattern, index); + output += translated.value; + index += translated.consumed; continue; } if (char === '*') { - if (pattern[index + 1] === '*') { - // `**/` spans zero or more directories; a trailing `**` spans anything. - if (pattern[index + 2] === '/') { - output += '(?:.*/)?'; - index += 2; - } else { - output += '.*'; - index += 1; - } - continue; - } - output += '[^/]*'; + const translated = translateStar(pattern, index); + output += translated.value; + index += translated.consumed; continue; } if (char === '?') { @@ -108,14 +95,9 @@ function translateGlob(pattern: string): string { continue; } if (char === '[') { - const close = pattern.indexOf(']', index + 1); - if (close > index) { - const body = pattern.slice(index + 1, close).replace(/\\/g, '\\\\'); - output += `[${body.startsWith('!') ? `^${body.slice(1)}` : body}]`; - index = close; - continue; - } - output += '\\['; + const translated = translateCharacterClass(pattern, index); + output += translated.value; + index += translated.consumed; continue; } output += escapeLiteral(char); @@ -123,6 +105,29 @@ function translateGlob(pattern: string): string { return output; } +function translateEscape(pattern: string, index: number): { value: string; consumed: number } { + const next = pattern[index + 1]; + return next === undefined + ? { value: '\\\\', consumed: 0 } + : { value: escapeLiteral(next), consumed: 1 }; +} + +function translateStar(pattern: string, index: number): { value: string; consumed: number } { + if (pattern[index + 1] !== '*') return { value: '[^/]*', consumed: 0 }; + // `**/` spans zero or more directories; a trailing `**` spans anything. + return pattern[index + 2] === '/' + ? { value: '(?:.*/)?', consumed: 2 } + : { value: '.*', consumed: 1 }; +} + +function translateCharacterClass(pattern: string, index: number): { value: string; consumed: number } { + const close = pattern.indexOf(']', index + 1); + if (close <= index) return { value: '\\[', consumed: 0 }; + const body = pattern.slice(index + 1, close).replace(/\\/g, '\\\\'); + const value = body.startsWith('!') ? `^${body.slice(1)}` : body; + return { value: `[${value}]`, consumed: close - index }; +} + function escapeLiteral(char: string): string { return /[.*+?^${}()|[\]\\]/.test(char) ? `\\${char}` : char; } diff --git a/src/core/record.ts b/src/core/record.ts index 4e989dee..6d5b52a8 100644 --- a/src/core/record.ts +++ b/src/core/record.ts @@ -54,6 +54,34 @@ export interface BuildRecordInput { generation?: BuildRecordGenerationInput; } +function recordStatement(input: BuildRecordInput, target: IntentTarget): IntentRecord['statement'] { + return { + kind: input.kind, + actor: input.actor ?? null, + action: input.action, + subject: input.subject ?? null, + object: input.object, + target, + modality: input.modality ?? 'unknown', + polarity: input.polarity ?? 'positive', + text: input.text, + }; +} + +function recordSource(input: BuildRecordInput, rawExcerpt: string): IntentRecord['source'] { + return { + kind: input.sourceKind, + path: input.sourcePath ?? null, + lines: input.sourceLines ?? null, + revision: input.revision ?? null, + symbol: input.symbol ?? null, + commitIndex: input.commitIndex ?? null, + extractor: input.extractor, + contentHash: sha256(rawExcerpt), + rawExcerpt, + }; +} + export function buildRecord(input: BuildRecordInput): IntentRecord { const target: IntentTarget = normalizeTarget(input.target); const rawExcerpt = input.rawExcerpt ?? input.text; @@ -72,29 +100,9 @@ export function buildRecord(input: BuildRecordInput): IntentRecord { return { schemaVersion: 't2c.intent/v1', id: createIntentId(seed, input.prefix ?? sourcePrefix(input.sourceKind)), - statement: { - kind: input.kind, - actor: input.actor ?? null, - action: input.action, - subject: input.subject ?? null, - object: input.object, - target, - modality: input.modality ?? 'unknown', - polarity: input.polarity ?? 'positive', - text: input.text, - }, + statement: recordStatement(input, target), lifecycle: { status: input.lifecycle }, - source: { - kind: input.sourceKind, - path: input.sourcePath ?? null, - lines: input.sourceLines ?? null, - revision: input.revision ?? null, - symbol: input.symbol ?? null, - commitIndex: input.commitIndex ?? null, - extractor: input.extractor, - contentHash: sha256(rawExcerpt), - rawExcerpt, - }, + source: recordSource(input, rawExcerpt), epistemic: { class: input.epistemicClass, confidence: clamp(input.confidence), diff --git a/src/core/schema-code-change-plan-value.ts b/src/core/schema-code-change-plan-value.ts new file mode 100644 index 00000000..899c4f07 --- /dev/null +++ b/src/core/schema-code-change-plan-value.ts @@ -0,0 +1,120 @@ +import { createCodeChangePlanHash, createCodeChangePlanId } from './id.js'; +import type { CodeChangePlan } from './types.js'; +import { + CODE_CHANGE_ACTIONS, + CODE_CHANGE_PLAN_ID, + CODE_CHANGE_RISK_LEVELS, + CONCLUSION_ID, + DIAGNOSTIC_ID, + RECORD_ID, + TODO_PRIORITIES, + TODO_PROPOSAL_ID, + confidence, + dateString, + enumValue, + exactKeys, + fingerprint, + knownReferences, + nonBlankString, + nonEmptyUniqueIdArray, + nonEmptyUniqueStringArray, + objectValue, + repositoryPath, + stringArray, + uniqueIdArray, +} from './schema-primitives.js'; +import { assertGroundedGenerationMetadata } from './schema-generation-validation.js'; + +export function assertCodeChangePlanValue( + value: unknown, + known: { + recordIds: Set; + diagnosticIds: Set; + conclusionIds: Set; + proposalIds: Set; + }, +): asserts value is CodeChangePlan { + const plan = objectValue(value, 'Code change plan'); + exactKeys(plan, [ + 'schemaVersion', 'id', 'planHash', 'status', 'createdAt', 'title', 'description', 'priority', + 'target', 'acceptanceCriteria', 'changes', 'risk', 'rollback', 'evidence', 'confidence', 'generation', + ], 'Code change plan'); + if (plan.schemaVersion !== 't2c.code-change-plan/v1') { + throw new Error('Unsupported code change plan schemaVersion'); + } + if (typeof plan.id !== 'string' || !CODE_CHANGE_PLAN_ID.test(plan.id)) { + throw new Error('Code change plan id must match CPLAN-<20 hex>'); + } + fingerprint(plan.planHash, `Code change plan ${plan.id}: planHash`); + if (plan.status !== 'proposed') throw new Error(`Code change plan ${plan.id}: status must be proposed`); + dateString(plan.createdAt, `Code change plan ${plan.id}: createdAt`); + nonBlankString(plan.title, `Code change plan ${plan.id}: title`); + nonBlankString(plan.description, `Code change plan ${plan.id}: description`); + enumValue(plan.priority, TODO_PRIORITIES, `Code change plan ${plan.id}: priority`); + const target = objectValue(plan.target, `Code change plan ${plan.id}: target`); + exactKeys(target, ['paths', 'symbols', 'tickets', 'versions'], `Code change plan ${plan.id}: target`); + for (const key of ['paths', 'symbols', 'tickets', 'versions'] as const) { + stringArray(target[key], `Code change plan ${plan.id}: target.${key}`, true); + if ((target[key] as string[]).some((item) => !item.trim())) { + throw new Error(`Code change plan ${plan.id}: target.${key} cannot contain blank values`); + } + } + const targetPaths = new Set((target.paths as string[]).map((item, index) => ( + repositoryPath(item, `Code change plan ${plan.id}: target.paths[${index}]`) + ))); + nonEmptyUniqueStringArray(plan.acceptanceCriteria, `Code change plan ${plan.id}: acceptanceCriteria`); + if (!Array.isArray(plan.changes) || plan.changes.length === 0) { + throw new Error(`Code change plan ${plan.id}: changes must be a non-empty array`); + } + const changePaths = new Set(); + for (const [index, rawChange] of plan.changes.entries()) { + const change = objectValue(rawChange, `Code change plan ${plan.id}: changes[${index}]`); + exactKeys(change, ['path', 'action', 'symbols', 'rationale'], `Code change plan ${plan.id}: changes[${index}]`); + nonBlankString(change.path, `Code change plan ${plan.id}: changes[${index}].path`); + const normalizedPath = repositoryPath(change.path, `Code change plan ${plan.id}: changes[${index}].path`); + if (!targetPaths.has(normalizedPath)) { + throw new Error(`Code change plan ${plan.id}: changes[${index}].path is not present in target.paths`); + } + enumValue(change.action, CODE_CHANGE_ACTIONS, `Code change plan ${plan.id}: changes[${index}].action`); + stringArray(change.symbols, `Code change plan ${plan.id}: changes[${index}].symbols`, true); + if ((change.symbols as string[]).some((item) => !item.trim())) { + throw new Error(`Code change plan ${plan.id}: changes[${index}].symbols cannot contain blank values`); + } + nonBlankString(change.rationale, `Code change plan ${plan.id}: changes[${index}].rationale`); + if (changePaths.has(normalizedPath)) { + throw new Error(`Code change plan ${plan.id}: duplicate change for ${normalizedPath}`); + } + changePaths.add(normalizedPath); + } + const risk = objectValue(plan.risk, `Code change plan ${plan.id}: risk`); + exactKeys(risk, ['level', 'reasons'], `Code change plan ${plan.id}: risk`); + enumValue(risk.level, CODE_CHANGE_RISK_LEVELS, `Code change plan ${plan.id}: risk.level`); + nonEmptyUniqueStringArray(risk.reasons, `Code change plan ${plan.id}: risk.reasons`); + nonBlankString(plan.rollback, `Code change plan ${plan.id}: rollback`); + const evidence = objectValue(plan.evidence, `Code change plan ${plan.id}: evidence`); + exactKeys(evidence, [ + 'graphFingerprint', 'recordIds', 'diagnosticIds', 'conclusionIds', 'proposalIds', + ], `Code change plan ${plan.id}: evidence`); + fingerprint(evidence.graphFingerprint, `Code change plan ${plan.id}: evidence.graphFingerprint`); + nonEmptyUniqueIdArray(evidence.recordIds, RECORD_ID, `Code change plan ${plan.id}: evidence.recordIds`); + nonEmptyUniqueIdArray(evidence.diagnosticIds, DIAGNOSTIC_ID, `Code change plan ${plan.id}: evidence.diagnosticIds`); + uniqueIdArray(evidence.conclusionIds, CONCLUSION_ID, `Code change plan ${plan.id}: evidence.conclusionIds`); + uniqueIdArray(evidence.proposalIds, TODO_PROPOSAL_ID, `Code change plan ${plan.id}: evidence.proposalIds`); + knownReferences(evidence.recordIds as string[], known.recordIds, `Code change plan ${plan.id}: evidence.recordIds`); + knownReferences(evidence.diagnosticIds as string[], known.diagnosticIds, `Code change plan ${plan.id}: evidence.diagnosticIds`); + knownReferences(evidence.conclusionIds as string[], known.conclusionIds, `Code change plan ${plan.id}: evidence.conclusionIds`); + knownReferences(evidence.proposalIds as string[], known.proposalIds, `Code change plan ${plan.id}: evidence.proposalIds`); + confidence(plan.confidence, `Code change plan ${plan.id}: confidence`); + assertGroundedGenerationMetadata(plan.generation, `Code change plan ${plan.id}: generation`); + + const semantic = plan as unknown as CodeChangePlan; + const expectedHash = createCodeChangePlanHash(semantic); + if (plan.planHash !== expectedHash) { + throw new Error(`Code change plan planHash does not match semantic content: expected ${expectedHash}`); + } + const expectedId = createCodeChangePlanId(semantic); + if (plan.id !== expectedId) { + throw new Error(`Code change plan id does not match semantic content: expected ${expectedId}`); + } +} + diff --git a/src/core/schema-generation-validation.ts b/src/core/schema-generation-validation.ts new file mode 100644 index 00000000..e89d1ede --- /dev/null +++ b/src/core/schema-generation-validation.ts @@ -0,0 +1,110 @@ +import type { GroundedGenerationMetadata } from './types.js'; +import { + GENERATION_EFFECTIVE_MODES, + GENERATION_REQUESTED_MODES, + RUNTIME_VERSION, + dateString, + enumValue, + exactKeys, + fingerprint, + nonBlankString, + nullableString, + objectValue, +} from './schema-primitives.js'; + +export function assertGenerationMatchesExtractor(value: unknown, extractor: string, name: string): void { + const generation = value as { generator: string; generatorVersion: string }; + const separator = extractor.lastIndexOf('@'); + const expectedGenerator = separator > 0 ? extractor.slice(0, separator) : extractor; + if (generation.generator !== expectedGenerator) { + throw new Error(`${name}.generator must match source.extractor (${expectedGenerator})`); + } + if (separator > 0 && generation.generatorVersion !== extractor.slice(separator + 1)) { + throw new Error(`${name}.generatorVersion must match source.extractor (${extractor.slice(separator + 1)})`); + } +} + +export function assertIntentGenerationMetadata(value: unknown, name: string): void { + const generation = objectValue(value, name); + exactKeys(generation, [ + 'generator', 'generatorVersion', 'runtimeVersion', 'requested', 'used', 'degraded', + 'fallbackReason', 'provider', 'model', 'responseId', + ], name); + nonBlankString(generation.generator, `${name}.generator`); + nonBlankString(generation.generatorVersion, `${name}.generatorVersion`); + if (typeof generation.runtimeVersion !== 'string' || !RUNTIME_VERSION.test(generation.runtimeVersion)) { + throw new Error(`${name}.runtimeVersion must be a semantic version`); + } + enumValue(generation.requested, GENERATION_EFFECTIVE_MODES, `${name}.requested`); + enumValue(generation.used, GENERATION_EFFECTIVE_MODES, `${name}.used`); + if (typeof generation.degraded !== 'boolean') throw new Error(`${name}.degraded must be a boolean`); + nullableString(generation.fallbackReason, `${name}.fallbackReason`); + nullableString(generation.provider, `${name}.provider`); + nullableString(generation.model, `${name}.model`); + nullableString(generation.responseId, `${name}.responseId`); + if (generation.used === 'llm') { + nonBlankString(generation.provider, `${name}.provider`); + nonBlankString(generation.model, `${name}.model`); + } else if (generation.provider !== null || generation.model !== null || generation.responseId !== null) { + throw new Error(`${name}: deterministic generation cannot claim an LLM provider, model or responseId`); + } + if (generation.degraded) { + if (generation.requested !== 'llm' || generation.used !== 'deterministic') { + throw new Error(`${name}: degraded generation must be an LLM request using deterministic fallback`); + } + nonBlankString(generation.fallbackReason, `${name}.fallbackReason`); + } else if (generation.fallbackReason !== null) { + throw new Error(`${name}.fallbackReason must be null when generation is not degraded`); + } +} + +export function assertGroundedGenerationMetadata(value: unknown, name: string): asserts value is GroundedGenerationMetadata { + // #lizard forgives + const generation = objectValue(value, name); + exactKeys(generation, [ + 'generator', 'generatorVersion', 'runtimeVersion', 'generatedAt', 'requestedMode', 'effectiveMode', + 'degraded', 'model', 'provider', 'responseId', 'configurationFingerprint', 'reason', + ], name); + nonBlankString(generation.generator, `${name}.generator`); + nonBlankString(generation.generatorVersion, `${name}.generatorVersion`); + if (typeof generation.runtimeVersion !== 'string' || !RUNTIME_VERSION.test(generation.runtimeVersion)) { + throw new Error(`${name}.runtimeVersion must be a semantic version`); + } + dateString(generation.generatedAt, `${name}.generatedAt`); + enumValue(generation.requestedMode, GENERATION_REQUESTED_MODES, `${name}.requestedMode`); + enumValue(generation.effectiveMode, GENERATION_EFFECTIVE_MODES, `${name}.effectiveMode`); + if (typeof generation.degraded !== 'boolean') throw new Error(`${name}.degraded must be a boolean`); + nullableString(generation.model, `${name}.model`); + nullableString(generation.provider, `${name}.provider`); + nullableString(generation.responseId, `${name}.responseId`); + fingerprint(generation.configurationFingerprint, `${name}.configurationFingerprint`); + nullableString(generation.reason, `${name}.reason`); + + if (generation.effectiveMode === 'llm') { + nonBlankString(generation.model, `${name}.model`); + nonBlankString(generation.provider, `${name}.provider`); + if (generation.degraded) throw new Error(`${name}.degraded must be false when effectiveMode is llm`); + } + if (generation.requestedMode === 'deterministic') { + if (generation.effectiveMode !== 'deterministic' || generation.degraded + || generation.model !== null || generation.provider !== null || generation.responseId !== null + || generation.reason !== null) { + throw new Error(`${name} deterministic mode cannot contain LLM or degradation metadata`); + } + } + if (generation.requestedMode === 'require-llm' && generation.effectiveMode !== 'llm') { + throw new Error(`${name} require-llm mode cannot use deterministic output`); + } + if (generation.requestedMode === 'prefer-llm' && generation.effectiveMode === 'deterministic' && !generation.degraded) { + throw new Error(`${name} prefer-llm deterministic output must be marked degraded`); + } + if (generation.degraded) { + if (generation.requestedMode !== 'prefer-llm' || generation.effectiveMode !== 'deterministic') { + throw new Error(`${name} degraded output is only valid for prefer-llm deterministic fallback`); + } + nonBlankString(generation.reason, `${name}.reason`); + } else if (generation.reason !== null) { + throw new Error(`${name}.reason must be null when output is not degraded`); + } +} + diff --git a/src/core/schema-intent-validation.ts b/src/core/schema-intent-validation.ts new file mode 100644 index 00000000..2ab25698 --- /dev/null +++ b/src/core/schema-intent-validation.ts @@ -0,0 +1,198 @@ +import { graphFingerprint } from './id.js'; +import type { IntentGraph, IntentGraphDiff, IntentRecord, IntentRelation } from './types.js'; +import { + ACTIONS, + EPISTEMIC_CLASSES, + FINGERPRINT, + LIFECYCLES, + MODALITIES, + POLARITIES, + RECORD_ID, + RELATION_ID, + RELATION_TYPES, + SOURCE_KINDS, + countMap, + countRecords, + dateString, + enumValue, + exactCounts, + exactKeys, + fingerprint, + isJsonValue, + nonEmptyString, + nonNegativeInteger, + nullableDate, + nullableString, + objectValue, + stringArray, +} from './schema-primitives.js'; +import { + assertGenerationMatchesExtractor, + assertIntentGenerationMetadata, +} from './schema-generation-validation.js'; + +export function assertIntentRecord(value: unknown): asserts value is IntentRecord { + // #lizard forgives + const record = objectValue(value, 'Intent record'); + exactKeys(record, ['schemaVersion', 'id', 'statement', 'lifecycle', 'source', 'epistemic', 'observedAt', 'metadata'], 'Intent record'); + if (record.schemaVersion !== 't2c.intent/v1') throw new Error('Unsupported intent schemaVersion'); + if (typeof record.id !== 'string' || !RECORD_ID.test(record.id)) throw new Error('Intent record id must match INT--<20 hex>'); + + const statement = objectValue(record.statement, `Intent ${record.id}: statement`); + exactKeys(statement, ['kind', 'actor', 'action', 'subject', 'object', 'target', 'modality', 'polarity', 'text'], `Intent ${record.id}: statement`); + nonEmptyString(statement.kind, `Intent ${record.id}: statement.kind`); + nullableString(statement.actor, `Intent ${record.id}: statement.actor`); + enumValue(statement.action, ACTIONS, `Intent ${record.id}: statement.action`); + nullableString(statement.subject, `Intent ${record.id}: statement.subject`); + nonEmptyString(statement.object, `Intent ${record.id}: statement.object`); + if (typeof statement.text !== 'string') throw new Error(`Intent ${record.id}: statement.text must be a string`); + enumValue(statement.modality, MODALITIES, `Intent ${record.id}: statement.modality`); + enumValue(statement.polarity, POLARITIES, `Intent ${record.id}: statement.polarity`); + + const target = objectValue(statement.target, `Intent ${record.id}: statement.target`); + exactKeys(target, ['paths', 'symbols', 'tickets', 'versions'], `Intent ${record.id}: statement.target`); + for (const key of ['paths', 'symbols', 'tickets', 'versions'] as const) { + stringArray(target[key], `Intent ${record.id}: statement.target.${key}`, true); + } + + const lifecycle = objectValue(record.lifecycle, `Intent ${record.id}: lifecycle`); + exactKeys(lifecycle, ['status'], `Intent ${record.id}: lifecycle`); + enumValue(lifecycle.status, LIFECYCLES, `Intent ${record.id}: lifecycle.status`); + + const source = objectValue(record.source, `Intent ${record.id}: source`); + exactKeys(source, ['kind', 'path', 'lines', 'revision', 'symbol', 'commitIndex', 'extractor', 'contentHash', 'rawExcerpt'], `Intent ${record.id}: source`); + enumValue(source.kind, SOURCE_KINDS, `Intent ${record.id}: source.kind`); + nullableString(source.path, `Intent ${record.id}: source.path`); + nullableString(source.revision, `Intent ${record.id}: source.revision`); + nullableString(source.symbol, `Intent ${record.id}: source.symbol`); + nullableString(source.rawExcerpt, `Intent ${record.id}: source.rawExcerpt`); + nonEmptyString(source.extractor, `Intent ${record.id}: source.extractor`); + if (typeof source.contentHash !== 'string' || !FINGERPRINT.test(source.contentHash)) { + throw new Error(`Intent ${record.id}: source.contentHash must be SHA-256`); + } + if (source.commitIndex !== null && (!Number.isInteger(source.commitIndex) || (source.commitIndex as number) < 1)) { + throw new Error(`Intent ${record.id}: source.commitIndex must be null or an integer >= 1`); + } + if (source.lines !== null) { + const lines = objectValue(source.lines, `Intent ${record.id}: source.lines`); + exactKeys(lines, ['start', 'end'], `Intent ${record.id}: source.lines`); + if (!Number.isInteger(lines.start) || (lines.start as number) < 1 || !Number.isInteger(lines.end) || (lines.end as number) < (lines.start as number)) { + throw new Error(`Intent ${record.id}: source.lines must be positive and end >= start`); + } + } + + const epistemic = objectValue(record.epistemic, `Intent ${record.id}: epistemic`); + exactKeys(epistemic, ['class', 'confidence', 'basis'], `Intent ${record.id}: epistemic`); + enumValue(epistemic.class, EPISTEMIC_CLASSES, `Intent ${record.id}: epistemic.class`); + if (typeof epistemic.confidence !== 'number' || !Number.isFinite(epistemic.confidence) + || epistemic.confidence < 0 || epistemic.confidence > 1) { + throw new Error(`Intent ${record.id}: epistemic.confidence must be between 0 and 1`); + } + stringArray(epistemic.basis, `Intent ${record.id}: epistemic.basis`, true); + nullableDate(record.observedAt, `Intent ${record.id}: observedAt`); + + const metadata = objectValue(record.metadata, `Intent ${record.id}: metadata`); + if (!isJsonValue(metadata)) throw new Error(`Intent ${record.id}: metadata must contain JSON values only`); + assertIntentGenerationMetadata(metadata.generation, `Intent ${record.id}: metadata.generation`); + assertGenerationMatchesExtractor(metadata.generation, source.extractor as string, `Intent ${record.id}: metadata.generation`); + if (epistemic.class === 'llm_inference' + && (metadata.generation as { used: unknown }).used !== 'llm') { + throw new Error(`Intent ${record.id}: llm_inference requires metadata.generation.used=llm`); + } +} + +export function assertIntentRecords(values: unknown): asserts values is IntentRecord[] { + if (!Array.isArray(values)) throw new Error('Intent records must be an array'); + values.forEach(assertIntentRecord); +} + +export function assertIntentGraph(value: unknown): asserts value is IntentGraph { + const graph = objectValue(value, 'Intent graph'); + exactKeys(graph, ['schemaVersion', 'generatedAt', 'fingerprint', 'records', 'relations', 'stats'], 'Intent graph'); + if (graph.schemaVersion !== 't2c.graph/v1') throw new Error('Unsupported graph schemaVersion'); + dateString(graph.generatedAt, 'Graph generatedAt'); + fingerprint(graph.fingerprint, 'Graph fingerprint'); + assertIntentRecords(graph.records); + if (!Array.isArray(graph.relations)) throw new Error('Graph relations must be an array'); + const recordIds = new Set((graph.records as IntentRecord[]).map((record) => record.id)); + if (recordIds.size !== (graph.records as IntentRecord[]).length) throw new Error('Graph record IDs must be unique'); + const relationIds = new Set(); + for (const relation of graph.relations) { + assertRelation(relation, recordIds); + if (relationIds.has((relation as IntentRelation).id)) throw new Error(`Duplicate relation id: ${(relation as IntentRelation).id}`); + relationIds.add((relation as IntentRelation).id); + } + const stats = objectValue(graph.stats, 'Graph stats'); + exactKeys(stats, ['bySource', 'byAction', 'byStatus'], 'Graph stats'); + countMap(stats.bySource, 'Graph stats.bySource'); + countMap(stats.byAction, 'Graph stats.byAction'); + countMap(stats.byStatus, 'Graph stats.byStatus'); + const records = graph.records as IntentRecord[]; + exactCounts(stats.bySource, countRecords(records, (record) => record.source.kind), 'Graph stats.bySource'); + exactCounts(stats.byAction, countRecords(records, (record) => record.statement.action), 'Graph stats.byAction'); + exactCounts(stats.byStatus, countRecords(records, (record) => record.lifecycle.status), 'Graph stats.byStatus'); + const expectedFingerprint = graphFingerprint(records, graph.relations as IntentRelation[]); + if (graph.fingerprint !== expectedFingerprint) throw new Error('Graph fingerprint does not match records and relations'); +} + +export function assertIntentGraphDiff(value: unknown): asserts value is IntentGraphDiff { + const diff = objectValue(value, 'Intent graph diff'); + exactKeys(diff, ['schemaVersion', 'generatedAt', 'fingerprint', 'beforeFingerprint', 'afterFingerprint', 'records', 'relations', 'summary'], 'Intent graph diff'); + if (diff.schemaVersion !== 't2c.diff/v1') throw new Error('Unsupported graph diff schemaVersion'); + dateString(diff.generatedAt, 'Graph diff generatedAt'); + fingerprint(diff.fingerprint, 'Graph diff fingerprint'); + fingerprint(diff.beforeFingerprint, 'Graph diff beforeFingerprint'); + fingerprint(diff.afterFingerprint, 'Graph diff afterFingerprint'); + + const records = objectValue(diff.records, 'Graph diff records'); + exactKeys(records, ['added', 'removed', 'changed', 'unchanged'], 'Graph diff records'); + assertIntentRecords(records.added); + assertIntentRecords(records.removed); + if (!Array.isArray(records.changed)) throw new Error('Graph diff changed records must be an array'); + for (const rawChange of records.changed) { + const change = objectValue(rawChange, 'Graph diff record change'); + exactKeys(change, ['identity', 'before', 'after', 'changedFields'], 'Graph diff record change'); + nonEmptyString(change.identity, 'Graph diff record change identity'); + assertIntentRecord(change.before); + assertIntentRecord(change.after); + stringArray(change.changedFields, 'Graph diff changedFields', true); + } + nonNegativeInteger(records.unchanged, 'Graph diff records.unchanged'); + + const relations = objectValue(diff.relations, 'Graph diff relations'); + exactKeys(relations, ['added', 'removed', 'unchanged'], 'Graph diff relations'); + if (!Array.isArray(relations.added) || !Array.isArray(relations.removed)) throw new Error('Graph diff relation sets must be arrays'); + [...relations.added, ...relations.removed].forEach((relation) => assertRelation(relation)); + nonNegativeInteger(relations.unchanged, 'Graph diff relations.unchanged'); + + const summary = objectValue(diff.summary, 'Graph diff summary'); + exactKeys(summary, ['recordsAdded', 'recordsRemoved', 'recordsChanged', 'recordsUnchanged', 'relationsAdded', 'relationsRemoved', 'relationsUnchanged'], 'Graph diff summary'); + for (const [key, count] of Object.entries(summary)) nonNegativeInteger(count, `Graph diff summary.${key}`); + const expectedCounts: Record = { + recordsAdded: (records.added as unknown[]).length, + recordsRemoved: (records.removed as unknown[]).length, + recordsChanged: (records.changed as unknown[]).length, + recordsUnchanged: records.unchanged as number, + relationsAdded: (relations.added as unknown[]).length, + relationsRemoved: (relations.removed as unknown[]).length, + relationsUnchanged: relations.unchanged as number, + }; + exactCounts(summary, expectedCounts, 'Graph diff summary'); +} + +function assertRelation(value: unknown, knownRecords?: Set): asserts value is IntentRelation { + const relation = objectValue(value, 'Intent relation'); + exactKeys(relation, ['id', 'from', 'to', 'type', 'confidence', 'basis'], 'Intent relation'); + if (typeof relation.id !== 'string' || !RELATION_ID.test(relation.id)) throw new Error('Intent relation id must match REL-<20 hex>'); + nonEmptyString(relation.from, `Relation ${relation.id}: from`); + nonEmptyString(relation.to, `Relation ${relation.id}: to`); + enumValue(relation.type, RELATION_TYPES, `Relation ${relation.id}: type`); + if (typeof relation.confidence !== 'number' || !Number.isFinite(relation.confidence) + || relation.confidence < 0 || relation.confidence > 1) { + throw new Error(`Relation ${relation.id}: confidence must be between 0 and 1`); + } + stringArray(relation.basis, `Relation ${relation.id}: basis`, true); + if (knownRecords && (!knownRecords.has(relation.from as string) || !knownRecords.has(relation.to as string))) { + throw new Error(`Relation ${relation.id} references unknown records`); + } +} diff --git a/src/core/schema-primitives.ts b/src/core/schema-primitives.ts new file mode 100644 index 00000000..4b70773a --- /dev/null +++ b/src/core/schema-primitives.ts @@ -0,0 +1,186 @@ +import type { IntentRecord, JsonValue, TodoProposal } from './types.js'; + +export const ACTIONS = new Set([ + 'add', 'fix', 'remove', 'refactor', 'test', 'document', 'configure', 'analyze', 'validate', + 'call', 'depend_on', 'declare', 'release', 'change', 'preserve', 'block', 'approve', 'unknown', +]); +export const MODALITIES = new Set(['required', 'recommended', 'optional', 'observed', 'claimed', 'unknown']); +export const POLARITIES = new Set(['positive', 'negative']); +export const LIFECYCLES = new Set([ + 'proposed', 'planned', 'in_progress', 'implemented', 'verified', 'released', 'completed', 'blocked', 'unknown', +]); +export const SOURCE_KINDS = new Set(['nl', 'git', 'ast', 'todo', 'changelog', 'document', 'agent_log', 'test', 'system']); +export const EPISTEMIC_CLASSES = new Set(['declaration', 'plan', 'claim', 'fact', 'inference', 'llm_inference']); +export const RELATION_TYPES = new Set([ + 'declares', 'plans', 'implements', 'modifies', 'tests', 'documents', 'releases', 'depends_on', + 'blocks', 'supersedes', 'contradicts', 'duplicates', 'evidenced_by', 'claimed_by', 'same_as', 'related_to', +]); +export const CONCLUSION_KINDS = new Set(['finding', 'risk', 'decision', 'recommendation']); +export const DIAGNOSTIC_SEVERITIES = new Set(['info', 'warning', 'review_required', 'blocking']); +export const TODO_PRIORITIES = new Set(['P0', 'P1', 'P2', 'P3']); +export const GENERATION_REQUESTED_MODES = new Set(['deterministic', 'prefer-llm', 'require-llm']); +export const GENERATION_EFFECTIVE_MODES = new Set(['deterministic', 'llm']); +export const RECORD_ID = /^INT-[A-Z]+-[a-f0-9]{20}$/; +export const RELATION_ID = /^REL-[a-f0-9]{20}$/; +export const DIAGNOSTIC_ID = /^DIAG-[a-f0-9]{20}$/; +export const CONCLUSION_ID = /^CONC-[a-f0-9]{20}$/; +export const TODO_PROPOSAL_ID = /^TPROP-[a-f0-9]{20}$/; +export const CODE_CHANGE_PLAN_ID = /^CPLAN-[a-f0-9]{20}$/; +export const CODE_CHANGE_ACTIONS = new Set(['create', 'modify', 'delete']); +export const CODE_CHANGE_RISK_LEVELS = new Set(['low', 'medium', 'high']); +export const FINGERPRINT = /^[a-f0-9]{64}$/; +export const RUNTIME_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +export const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + +export function objectValue(value: unknown, name: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${name} must be an object`); + return value as Record; +} + +export function exactKeys(value: Record, expected: string[], name: string): void { + const expectedSet = new Set(expected); + const missing = expected.filter((key) => !(key in value)); + const extra = Object.keys(value).filter((key) => !expectedSet.has(key)); + if (missing.length) throw new Error(`${name} is missing: ${missing.join(', ')}`); + if (extra.length) throw new Error(`${name} has unsupported fields: ${extra.join(', ')}`); +} + +export function nonEmptyString(value: unknown, name: string): asserts value is string { + if (typeof value !== 'string' || !value.length) throw new Error(`${name} must be a non-empty string`); +} + +export function nonBlankString(value: unknown, name: string): asserts value is string { + if (typeof value !== 'string' || !value.trim().length) throw new Error(`${name} must be a non-blank string`); +} + +export function nullableString(value: unknown, name: string): void { + if (value !== null && typeof value !== 'string') throw new Error(`${name} must be a string or null`); +} + +export function enumValue(value: unknown, allowed: Set, name: string): asserts value is string { + if (typeof value !== 'string' || !allowed.has(value)) throw new Error(`${name} has unsupported value: ${String(value)}`); +} + +export function stringArray(value: unknown, name: string, unique = false): asserts value is string[] { + if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) throw new Error(`${name} must be an array of strings`); + if (unique && new Set(value).size !== value.length) throw new Error(`${name} must contain unique values`); +} + +export function nonEmptyUniqueStringArray(value: unknown, name: string): asserts value is string[] { + stringArray(value, name, true); + if (!value.length || value.some((item) => !item.trim().length)) { + throw new Error(`${name} must contain at least one non-blank string`); + } + if (new Set(value.map((item) => item.trim())).size !== value.length) { + throw new Error(`${name} must remain unique after trimming whitespace`); + } +} + +export function repositoryPath(value: unknown, name: string): string { + nonBlankString(value, name); + const normalized = value.trim().replace(/\\/g, '/'); + if (normalized.startsWith('/') || normalized.split('/').some((part) => part === '..')) { + throw new Error(`${name} must be a relative repository path without parent traversal`); + } + return normalized; +} + +export function exactStringSet(actual: string[], expected: string[], name: string): void { + const normalizedActual = [...actual].sort(); + if (normalizedActual.length !== expected.length + || normalizedActual.some((value, index) => value !== expected[index])) { + throw new Error(`${name} does not match the grounded diagnostic set`); + } +} + +export function uniqueIdArray(value: unknown, pattern: RegExp, name: string): asserts value is string[] { + stringArray(value, name, true); + if (value.some((item) => !pattern.test(item))) throw new Error(`${name} contains an invalid id`); +} + +export function nonEmptyUniqueIdArray(value: unknown, pattern: RegExp, name: string): asserts value is string[] { + uniqueIdArray(value, pattern, name); + if (!value.length) throw new Error(`${name} must contain at least one id`); +} + +export function knownReferences(values: string[], known: Set, name: string): void { + const unknown = values.filter((value) => !known.has(value)); + if (unknown.length) throw new Error(`${name} references unknown ids: ${unknown.join(', ')}`); +} + +export function confidence(value: unknown, name: string): asserts value is number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) { + throw new Error(`${name} must be between 0 and 1`); + } +} + +export function assertAcyclicProposalDependencies(proposals: TodoProposal[]): void { + const byId = new Map(proposals.map((proposal) => [proposal.id, proposal])); + const visiting = new Set(); + const visited = new Set(); + const visit = (id: string, chain: string[]): void => { + if (visiting.has(id)) { + const start = chain.indexOf(id); + throw new Error(`TODO proposal dependency cycle: ${[...chain.slice(Math.max(0, start)), id].join(' -> ')}`); + } + if (visited.has(id)) return; + visiting.add(id); + for (const dependency of byId.get(id)?.dependencies ?? []) visit(dependency, [...chain, id]); + visiting.delete(id); + visited.add(id); + }; + for (const proposal of proposals) visit(proposal.id, []); +} + +export function dateString(value: unknown, name: string): asserts value is string { + if (typeof value !== 'string' || !ISO_DATE_TIME.test(value) || !Number.isFinite(Date.parse(value))) { + throw new Error(`${name} must be an ISO date-time string`); + } +} + +export function nullableDate(value: unknown, name: string): void { + if (value !== null) dateString(value, name); +} + +export function fingerprint(value: unknown, name: string): void { + if (typeof value !== 'string' || !FINGERPRINT.test(value)) throw new Error(`${name} must be SHA-256`); +} + +export function nonNegativeInteger(value: unknown, name: string): void { + if (!Number.isInteger(value) || (value as number) < 0) throw new Error(`${name} must be an integer >= 0`); +} + +export function countMap(value: unknown, name: string): void { + const map = objectValue(value, name); + for (const [key, count] of Object.entries(map)) { + if (!key) throw new Error(`${name} keys must be non-empty`); + nonNegativeInteger(count, `${name}.${key}`); + } +} + +export function countRecords(records: IntentRecord[], selector: (record: IntentRecord) => string): Record { + const counts: Record = {}; + for (const record of records) { + const key = selector(record); + counts[key] = (counts[key] ?? 0) + 1; + } + return counts; +} + +export function exactCounts(value: unknown, expected: Record, name: string): void { + const actual = objectValue(value, name); + const keys = [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort(); + for (const key of keys) { + if (actual[key] !== expected[key]) { + throw new Error(`${name} is inconsistent for ${key}: expected ${expected[key] ?? 0}, received ${String(actual[key] ?? 0)}`); + } + } +} + +export function isJsonValue(value: unknown): value is JsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; + if (typeof value === 'number') return Number.isFinite(value); + if (Array.isArray(value)) return value.every(isJsonValue); + if (value && typeof value === 'object') return Object.values(value as Record).every(isJsonValue); + return false; +} diff --git a/src/core/schema-validation.ts b/src/core/schema-validation.ts new file mode 100644 index 00000000..265862ca --- /dev/null +++ b/src/core/schema-validation.ts @@ -0,0 +1,413 @@ +import { + createConclusionId, + createTodoProposalId, +} from './id.js'; +import type { + CodeChangeAcceptance, + CodeChangePlan, + Conclusion, + DiagnosticReport, + GroundedGenerationMetadata, + IntentGraph, + TodoProposal, +} from './types.js'; +import { + CONCLUSION_ID, + CONCLUSION_KINDS, + DIAGNOSTIC_ID, + DIAGNOSTIC_SEVERITIES, + RECORD_ID, + TODO_PRIORITIES, + TODO_PROPOSAL_ID, + assertAcyclicProposalDependencies, + confidence, + dateString, + enumValue, + exactKeys, + exactStringSet, + fingerprint, + knownReferences, + nonBlankString, + nonEmptyUniqueIdArray, + nonEmptyUniqueStringArray, + objectValue, + stringArray, + uniqueIdArray, +} from './schema-primitives.js'; + +import { assertCodeChangePlanValue } from './schema-code-change-plan-value.js'; +import { assertGroundedGenerationMetadata } from './schema-generation-validation.js'; +import { assertIntentGraph } from './schema-intent-validation.js'; +export { + assertIntentGraph, + assertIntentGraphDiff, + assertIntentRecord, + assertIntentRecords, +} from './schema-intent-validation.js'; +export { assertGroundedGenerationMetadata } from './schema-generation-validation.js'; + + +export interface GroundedValidationContext { + graph: IntentGraph; + diagnostics: DiagnosticReport; +} + +export interface TodoProposalValidationContext extends GroundedValidationContext { + conclusions: Conclusion[]; +} + +export interface CodeChangePlanValidationContext extends GroundedValidationContext { + conclusions?: Conclusion[]; + proposals?: TodoProposal[]; +} + +export interface CodeChangeAcceptanceValidationContext { + plan: CodeChangePlan; + before: GroundedValidationContext; + after: GroundedValidationContext; +} + +export function assertConclusion( + value: unknown, + context: GroundedValidationContext, +): asserts value is Conclusion { + const known = validateGroundedContext(context); + assertConclusionValue(value, known.recordIds, known.diagnosticIds); +} + +export function assertConclusions( + values: unknown, + context: GroundedValidationContext, +): asserts values is Conclusion[] { + if (!Array.isArray(values)) throw new Error('Conclusions must be an array'); + const known = validateGroundedContext(context); + const ids = new Set(); + for (const value of values) { + assertConclusionValue(value, known.recordIds, known.diagnosticIds); + const id = (value as Conclusion).id; + if (ids.has(id)) throw new Error(`Duplicate conclusion id: ${id}`); + ids.add(id); + } +} + +export function assertTodoProposal( + value: unknown, + context: TodoProposalValidationContext, +): asserts value is TodoProposal { + const known = validateTodoProposalContext(context); + assertTodoProposalValue(value, known.recordIds, known.diagnosticIds, known.conclusionIds); +} + +export function assertTodoProposals( + values: unknown, + context: TodoProposalValidationContext, +): asserts values is TodoProposal[] { + if (!Array.isArray(values)) throw new Error('TODO proposals must be an array'); + const known = validateTodoProposalContext(context); + const proposalIds = new Set(); + for (const value of values) { + assertTodoProposalValue(value, known.recordIds, known.diagnosticIds, known.conclusionIds); + const id = (value as TodoProposal).id; + if (proposalIds.has(id)) throw new Error(`Duplicate TODO proposal id: ${id}`); + proposalIds.add(id); + } + for (const proposal of values as TodoProposal[]) { + for (const dependency of proposal.dependencies) { + if (!proposalIds.has(dependency)) { + throw new Error(`TODO proposal ${proposal.id} references unknown dependency ${dependency}`); + } + } + } + assertAcyclicProposalDependencies(values as TodoProposal[]); +} + +export function assertCodeChangePlan( + value: unknown, + context: CodeChangePlanValidationContext, +): asserts value is CodeChangePlan { + const known = validateCodeChangePlanContext(context); + assertCodeChangePlanValue(value, known); + assertPlanGraphFingerprint(value, context.graph.fingerprint); +} + +export function assertCodeChangePlans( + values: unknown, + context: CodeChangePlanValidationContext, +): asserts values is CodeChangePlan[] { + if (!Array.isArray(values)) throw new Error('Code change plans must be an array'); + const known = validateCodeChangePlanContext(context); + const ids = new Set(); + for (const value of values) { + assertCodeChangePlanValue(value, known); + assertPlanGraphFingerprint(value, context.graph.fingerprint); + const id = (value as CodeChangePlan).id; + if (ids.has(id)) throw new Error(`Duplicate code change plan id: ${id}`); + ids.add(id); + } +} + +/** + * Validate persisted plans for rendering when their source graph and + * diagnostics are not loaded. Evidence references remain syntax-checked and + * content-bound by each plan hash; the supplied graph fingerprint must match. + */ +export function assertCodeChangePlansForReview( + values: unknown, + graphFingerprintValue: string, +): asserts values is CodeChangePlan[] { + if (!Array.isArray(values)) throw new Error('Code change plans must be an array'); + fingerprint(graphFingerprintValue, 'Code change review graphFingerprint'); + const ids = new Set(); + for (const value of values) { + const plan = objectValue(value, 'Code change plan'); + const evidence = objectValue(plan.evidence, 'Code change plan evidence'); + uniqueIdArray(evidence.recordIds, RECORD_ID, 'Code change plan evidence.recordIds'); + uniqueIdArray(evidence.diagnosticIds, DIAGNOSTIC_ID, 'Code change plan evidence.diagnosticIds'); + uniqueIdArray(evidence.conclusionIds, CONCLUSION_ID, 'Code change plan evidence.conclusionIds'); + uniqueIdArray(evidence.proposalIds, TODO_PROPOSAL_ID, 'Code change plan evidence.proposalIds'); + assertCodeChangePlanValue(value, { + recordIds: new Set(evidence.recordIds as string[]), + diagnosticIds: new Set(evidence.diagnosticIds as string[]), + conclusionIds: new Set(evidence.conclusionIds as string[]), + proposalIds: new Set(evidence.proposalIds as string[]), + }); + assertPlanGraphFingerprint(value, graphFingerprintValue); + const id = (value as CodeChangePlan).id; + if (ids.has(id)) throw new Error(`Duplicate code change plan id: ${id}`); + ids.add(id); + } +} + +/** + * Validate a persisted plan before acceptance when its full conclusion and + * TODO-proposal objects are no longer present. Their IDs remain syntax-checked + * and content-bound by the plan hash; records and diagnostics stay grounded in + * the supplied before graph. + */ +export function assertCodeChangePlanForAcceptance( + value: unknown, + context: GroundedValidationContext, +): asserts value is CodeChangePlan { + const known = validateGroundedContext(context); + const plan = objectValue(value, 'Code change plan'); + const evidence = objectValue(plan.evidence, 'Code change plan evidence'); + uniqueIdArray(evidence.conclusionIds, CONCLUSION_ID, 'Code change plan evidence.conclusionIds'); + uniqueIdArray(evidence.proposalIds, TODO_PROPOSAL_ID, 'Code change plan evidence.proposalIds'); + assertCodeChangePlanValue(value, { + ...known, + conclusionIds: new Set(evidence.conclusionIds as string[]), + proposalIds: new Set(evidence.proposalIds as string[]), + }); + assertPlanGraphFingerprint(value, context.graph.fingerprint); +} + +function assertPlanGraphFingerprint(plan: CodeChangePlan, graphFingerprintValue: string): void { + if (plan.evidence.graphFingerprint !== graphFingerprintValue) { + throw new Error('Code change plan evidence.graphFingerprint does not match its graph'); + } +} + +export function assertCodeChangeAcceptance( + value: unknown, + context: CodeChangeAcceptanceValidationContext, +): asserts value is CodeChangeAcceptance { + assertCodeChangePlanForAcceptance(context.plan, context.before); + const beforeKnown = validateGroundedContext(context.before); + const afterKnown = validateGroundedContext(context.after); + const acceptance = objectValue(value, 'Code change acceptance'); + exactKeys(acceptance, [ + 'schemaVersion', 'planId', 'planHash', 'beforeGraphFingerprint', 'afterGraphFingerprint', + 'beforeDiagnosticIds', 'afterDiagnosticIds', 'clearedDiagnosticIds', 'remainingDiagnosticIds', + 'newBlockingDiagnosticIds', 'accepted', 'reasons', 'evaluatedAt', 'generation', + ], 'Code change acceptance'); + if (acceptance.schemaVersion !== 't2c.code-change-acceptance/v1') { + throw new Error('Unsupported code change acceptance schemaVersion'); + } + if (acceptance.planId !== context.plan.id) throw new Error('Code change acceptance planId does not match its plan'); + if (acceptance.planHash !== context.plan.planHash) throw new Error('Code change acceptance planHash does not match its plan'); + if (acceptance.beforeGraphFingerprint !== context.before.graph.fingerprint) { + throw new Error('Code change acceptance beforeGraphFingerprint does not match its graph'); + } + if (acceptance.afterGraphFingerprint !== context.after.graph.fingerprint) { + throw new Error('Code change acceptance afterGraphFingerprint does not match its graph'); + } + for (const key of [ + 'beforeDiagnosticIds', 'afterDiagnosticIds', 'clearedDiagnosticIds', 'remainingDiagnosticIds', + 'newBlockingDiagnosticIds', + ] as const) { + uniqueIdArray(acceptance[key], DIAGNOSTIC_ID, `Code change acceptance ${key}`); + } + const beforeIds = [...beforeKnown.diagnosticIds].sort(); + const afterIds = [...afterKnown.diagnosticIds].sort(); + const targeted = [...context.plan.evidence.diagnosticIds].sort(); + const expectedCleared = targeted.filter((id) => !afterKnown.diagnosticIds.has(id)); + const expectedRemaining = targeted.filter((id) => afterKnown.diagnosticIds.has(id)); + const expectedBlocking = context.after.diagnostics.diagnostics + .filter((item) => item.severity === 'blocking' && !beforeKnown.diagnosticIds.has(item.id)) + .map((item) => item.id) + .sort(); + exactStringSet(acceptance.beforeDiagnosticIds as string[], beforeIds, 'Code change acceptance beforeDiagnosticIds'); + exactStringSet(acceptance.afterDiagnosticIds as string[], afterIds, 'Code change acceptance afterDiagnosticIds'); + exactStringSet(acceptance.clearedDiagnosticIds as string[], expectedCleared, 'Code change acceptance clearedDiagnosticIds'); + exactStringSet(acceptance.remainingDiagnosticIds as string[], expectedRemaining, 'Code change acceptance remainingDiagnosticIds'); + exactStringSet(acceptance.newBlockingDiagnosticIds as string[], expectedBlocking, 'Code change acceptance newBlockingDiagnosticIds'); + const expectedAccepted = expectedRemaining.length === 0 && expectedBlocking.length === 0; + if (acceptance.accepted !== expectedAccepted) throw new Error('Code change acceptance accepted flag is inconsistent'); + nonEmptyUniqueStringArray(acceptance.reasons, 'Code change acceptance reasons'); + dateString(acceptance.evaluatedAt, 'Code change acceptance evaluatedAt'); + assertGroundedGenerationMetadata(acceptance.generation, 'Code change acceptance generation'); + if ((acceptance.generation as GroundedGenerationMetadata).generatedAt !== acceptance.evaluatedAt) { + throw new Error('Code change acceptance generation.generatedAt must match evaluatedAt'); + } +} + +function assertConclusionValue( + value: unknown, + recordIds: Set, + diagnosticIds: Set, +): asserts value is Conclusion { + const conclusion = objectValue(value, 'Conclusion'); + exactKeys(conclusion, [ + 'schemaVersion', 'id', 'kind', 'title', 'detail', 'severity', 'diagnosticIds', 'recordIds', 'confidence', 'generation', + ], 'Conclusion'); + if (conclusion.schemaVersion !== 't2c.conclusion/v1') throw new Error('Unsupported conclusion schemaVersion'); + if (typeof conclusion.id !== 'string' || !CONCLUSION_ID.test(conclusion.id)) { + throw new Error('Conclusion id must match CONC-<20 hex>'); + } + enumValue(conclusion.kind, CONCLUSION_KINDS, `Conclusion ${conclusion.id}: kind`); + nonBlankString(conclusion.title, `Conclusion ${conclusion.id}: title`); + nonBlankString(conclusion.detail, `Conclusion ${conclusion.id}: detail`); + enumValue(conclusion.severity, DIAGNOSTIC_SEVERITIES, `Conclusion ${conclusion.id}: severity`); + nonEmptyUniqueIdArray(conclusion.diagnosticIds, DIAGNOSTIC_ID, `Conclusion ${conclusion.id}: diagnosticIds`); + nonEmptyUniqueIdArray(conclusion.recordIds, RECORD_ID, `Conclusion ${conclusion.id}: recordIds`); + knownReferences(conclusion.diagnosticIds as string[], diagnosticIds, `Conclusion ${conclusion.id}: diagnosticIds`); + knownReferences(conclusion.recordIds as string[], recordIds, `Conclusion ${conclusion.id}: recordIds`); + confidence(conclusion.confidence, `Conclusion ${conclusion.id}: confidence`); + assertGroundedGenerationMetadata(conclusion.generation, `Conclusion ${conclusion.id}: generation`); + const expectedId = createConclusionId(conclusion as unknown as Conclusion); + if (conclusion.id !== expectedId) throw new Error(`Conclusion id does not match semantic content: expected ${expectedId}`); +} + +function assertTodoProposalValue( + value: unknown, + recordIds: Set, + diagnosticIds: Set, + conclusionIds: Set, +): asserts value is TodoProposal { + const proposal = objectValue(value, 'TODO proposal'); + exactKeys(proposal, [ + 'schemaVersion', 'id', 'title', 'description', 'priority', 'status', 'target', 'acceptanceCriteria', + 'dependencies', 'conclusionIds', 'diagnosticIds', 'recordIds', 'confidence', 'generation', + ], 'TODO proposal'); + if (proposal.schemaVersion !== 't2c.todo-proposal/v1') throw new Error('Unsupported TODO proposal schemaVersion'); + if (typeof proposal.id !== 'string' || !TODO_PROPOSAL_ID.test(proposal.id)) { + throw new Error('TODO proposal id must match TPROP-<20 hex>'); + } + nonBlankString(proposal.title, `TODO proposal ${proposal.id}: title`); + nonBlankString(proposal.description, `TODO proposal ${proposal.id}: description`); + enumValue(proposal.priority, TODO_PRIORITIES, `TODO proposal ${proposal.id}: priority`); + if (proposal.status !== 'proposed') throw new Error(`TODO proposal ${proposal.id}: status must be proposed`); + const target = objectValue(proposal.target, `TODO proposal ${proposal.id}: target`); + exactKeys(target, ['paths', 'symbols', 'tickets', 'versions'], `TODO proposal ${proposal.id}: target`); + for (const key of ['paths', 'symbols', 'tickets', 'versions'] as const) { + stringArray(target[key], `TODO proposal ${proposal.id}: target.${key}`, true); + if ((target[key] as string[]).some((item) => !item.trim())) { + throw new Error(`TODO proposal ${proposal.id}: target.${key} cannot contain blank values`); + } + } + nonEmptyUniqueStringArray(proposal.acceptanceCriteria, `TODO proposal ${proposal.id}: acceptanceCriteria`); + uniqueIdArray(proposal.dependencies, TODO_PROPOSAL_ID, `TODO proposal ${proposal.id}: dependencies`); + if ((proposal.dependencies as string[]).includes(proposal.id as string)) { + throw new Error(`TODO proposal ${proposal.id} cannot depend on itself`); + } + nonEmptyUniqueIdArray(proposal.conclusionIds, CONCLUSION_ID, `TODO proposal ${proposal.id}: conclusionIds`); + nonEmptyUniqueIdArray(proposal.diagnosticIds, DIAGNOSTIC_ID, `TODO proposal ${proposal.id}: diagnosticIds`); + nonEmptyUniqueIdArray(proposal.recordIds, RECORD_ID, `TODO proposal ${proposal.id}: recordIds`); + knownReferences(proposal.conclusionIds as string[], conclusionIds, `TODO proposal ${proposal.id}: conclusionIds`); + knownReferences(proposal.diagnosticIds as string[], diagnosticIds, `TODO proposal ${proposal.id}: diagnosticIds`); + knownReferences(proposal.recordIds as string[], recordIds, `TODO proposal ${proposal.id}: recordIds`); + confidence(proposal.confidence, `TODO proposal ${proposal.id}: confidence`); + assertGroundedGenerationMetadata(proposal.generation, `TODO proposal ${proposal.id}: generation`); + const expectedId = createTodoProposalId(proposal as unknown as TodoProposal); + if (proposal.id !== expectedId) throw new Error(`TODO proposal id does not match semantic content: expected ${expectedId}`); +} + +function validateGroundedContext(context: GroundedValidationContext): { + recordIds: Set; + diagnosticIds: Set; +} { + assertIntentGraph(context.graph); + const report = objectValue(context.diagnostics, 'Diagnostic report'); + if (report.schemaVersion !== 't2c.diagnostics/v1') throw new Error('Unsupported diagnostic schemaVersion'); + if (report.graphFingerprint !== context.graph.fingerprint) { + throw new Error('Diagnostic report does not describe the supplied graph'); + } + if (!Array.isArray(report.diagnostics)) throw new Error('Diagnostic report diagnostics must be an array'); + const diagnosticIds = new Set(); + for (const value of report.diagnostics) { + const diagnostic = objectValue(value, 'Diagnostic'); + if (typeof diagnostic.id !== 'string' || !DIAGNOSTIC_ID.test(diagnostic.id)) { + throw new Error('Diagnostic id must match DIAG-<20 hex>'); + } + if (diagnosticIds.has(diagnostic.id)) throw new Error(`Duplicate diagnostic id: ${diagnostic.id}`); + diagnosticIds.add(diagnostic.id); + } + return { + recordIds: new Set(context.graph.records.map((record) => record.id)), + diagnosticIds, + }; +} + +function validateTodoProposalContext(context: TodoProposalValidationContext): { + recordIds: Set; + diagnosticIds: Set; + conclusionIds: Set; +} { + const known = validateGroundedContext(context); + assertConclusions(context.conclusions, context); + return { + ...known, + conclusionIds: new Set(context.conclusions.map((conclusion) => conclusion.id)), + }; +} + +function validateCodeChangePlanContext(context: CodeChangePlanValidationContext): { + recordIds: Set; + diagnosticIds: Set; + conclusionIds: Set; + proposalIds: Set; +} { + const known = validateGroundedContext(context); + const conclusions = context.conclusions ?? []; + const proposals = context.proposals ?? []; + if (conclusions.length) assertConclusions(conclusions, context); + // Full proposal contracts need conclusions. When only proposal IDs are + // supplied as evidence references, accept the IDs after a light shape check. + if (proposals.length && conclusions.length) { + assertTodoProposals(proposals, { graph: context.graph, diagnostics: context.diagnostics, conclusions }); + } else if (proposals.length) { + const referencedConclusionIds = new Set(); + for (const [index, value] of proposals.entries()) { + const proposal = objectValue(value, `TODO proposal reference[${index}]`); + uniqueIdArray(proposal.conclusionIds, CONCLUSION_ID, `TODO proposal reference[${index}].conclusionIds`); + for (const id of proposal.conclusionIds as string[]) referencedConclusionIds.add(id); + } + const proposalIds = new Set(); + for (const proposal of proposals) { + assertTodoProposalValue( + proposal, + known.recordIds, + known.diagnosticIds, + referencedConclusionIds, + ); + if (proposalIds.has(proposal.id)) throw new Error(`Duplicate TODO proposal id: ${proposal.id}`); + proposalIds.add(proposal.id); + } + } + return { + ...known, + conclusionIds: new Set(conclusions.map((item) => item.id)), + proposalIds: new Set(proposals.map((item) => item.id)), + }; +} + diff --git a/src/core/schema.ts b/src/core/schema.ts index af85c7ed..7c5c85f5 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -1,922 +1,23 @@ -import { - createCodeChangePlanHash, - createCodeChangePlanId, - createConclusionId, - createTodoProposalId, - graphFingerprint, -} from './id.js'; -import type { - CodeChangeAcceptance, - CodeChangePlan, - Conclusion, - DiagnosticReport, - GroundedGenerationMetadata, - IntentGraph, - IntentGraphDiff, - IntentRecord, - IntentRelation, - JsonValue, - TodoProposal, -} from './types.js'; - -const ACTIONS = new Set([ - 'add', 'fix', 'remove', 'refactor', 'test', 'document', 'configure', 'analyze', 'validate', - 'call', 'depend_on', 'declare', 'release', 'change', 'preserve', 'block', 'approve', 'unknown', -]); -const MODALITIES = new Set(['required', 'recommended', 'optional', 'observed', 'claimed', 'unknown']); -const POLARITIES = new Set(['positive', 'negative']); -const LIFECYCLES = new Set([ - 'proposed', 'planned', 'in_progress', 'implemented', 'verified', 'released', 'completed', 'blocked', 'unknown', -]); -const SOURCE_KINDS = new Set(['nl', 'git', 'ast', 'todo', 'changelog', 'document', 'agent_log', 'test', 'system']); -const EPISTEMIC_CLASSES = new Set(['declaration', 'plan', 'claim', 'fact', 'inference', 'llm_inference']); -const RELATION_TYPES = new Set([ - 'declares', 'plans', 'implements', 'modifies', 'tests', 'documents', 'releases', 'depends_on', - 'blocks', 'supersedes', 'contradicts', 'duplicates', 'evidenced_by', 'claimed_by', 'same_as', 'related_to', -]); -const CONCLUSION_KINDS = new Set(['finding', 'risk', 'decision', 'recommendation']); -const DIAGNOSTIC_SEVERITIES = new Set(['info', 'warning', 'review_required', 'blocking']); -const TODO_PRIORITIES = new Set(['P0', 'P1', 'P2', 'P3']); -const GENERATION_REQUESTED_MODES = new Set(['deterministic', 'prefer-llm', 'require-llm']); -const GENERATION_EFFECTIVE_MODES = new Set(['deterministic', 'llm']); -const RECORD_ID = /^INT-[A-Z]+-[a-f0-9]{20}$/; -const RELATION_ID = /^REL-[a-f0-9]{20}$/; -const DIAGNOSTIC_ID = /^DIAG-[a-f0-9]{20}$/; -const CONCLUSION_ID = /^CONC-[a-f0-9]{20}$/; -const TODO_PROPOSAL_ID = /^TPROP-[a-f0-9]{20}$/; -const CODE_CHANGE_PLAN_ID = /^CPLAN-[a-f0-9]{20}$/; -const CODE_CHANGE_ACTIONS = new Set(['create', 'modify', 'delete']); -const CODE_CHANGE_RISK_LEVELS = new Set(['low', 'medium', 'high']); -const FINGERPRINT = /^[a-f0-9]{64}$/; -const RUNTIME_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; -const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; - -export interface GroundedValidationContext { - graph: IntentGraph; - diagnostics: DiagnosticReport; -} - -export interface TodoProposalValidationContext extends GroundedValidationContext { - conclusions: Conclusion[]; -} - -export interface CodeChangePlanValidationContext extends GroundedValidationContext { - conclusions?: Conclusion[]; - proposals?: TodoProposal[]; -} - -export interface CodeChangeAcceptanceValidationContext { - plan: CodeChangePlan; - before: GroundedValidationContext; - after: GroundedValidationContext; -} - -export function assertIntentRecord(value: unknown): asserts value is IntentRecord { - const record = objectValue(value, 'Intent record'); - exactKeys(record, ['schemaVersion', 'id', 'statement', 'lifecycle', 'source', 'epistemic', 'observedAt', 'metadata'], 'Intent record'); - if (record.schemaVersion !== 't2c.intent/v1') throw new Error('Unsupported intent schemaVersion'); - if (typeof record.id !== 'string' || !RECORD_ID.test(record.id)) throw new Error('Intent record id must match INT--<20 hex>'); - - const statement = objectValue(record.statement, `Intent ${record.id}: statement`); - exactKeys(statement, ['kind', 'actor', 'action', 'subject', 'object', 'target', 'modality', 'polarity', 'text'], `Intent ${record.id}: statement`); - nonEmptyString(statement.kind, `Intent ${record.id}: statement.kind`); - nullableString(statement.actor, `Intent ${record.id}: statement.actor`); - enumValue(statement.action, ACTIONS, `Intent ${record.id}: statement.action`); - nullableString(statement.subject, `Intent ${record.id}: statement.subject`); - nonEmptyString(statement.object, `Intent ${record.id}: statement.object`); - if (typeof statement.text !== 'string') throw new Error(`Intent ${record.id}: statement.text must be a string`); - enumValue(statement.modality, MODALITIES, `Intent ${record.id}: statement.modality`); - enumValue(statement.polarity, POLARITIES, `Intent ${record.id}: statement.polarity`); - - const target = objectValue(statement.target, `Intent ${record.id}: statement.target`); - exactKeys(target, ['paths', 'symbols', 'tickets', 'versions'], `Intent ${record.id}: statement.target`); - for (const key of ['paths', 'symbols', 'tickets', 'versions'] as const) { - stringArray(target[key], `Intent ${record.id}: statement.target.${key}`, true); - } - - const lifecycle = objectValue(record.lifecycle, `Intent ${record.id}: lifecycle`); - exactKeys(lifecycle, ['status'], `Intent ${record.id}: lifecycle`); - enumValue(lifecycle.status, LIFECYCLES, `Intent ${record.id}: lifecycle.status`); - - const source = objectValue(record.source, `Intent ${record.id}: source`); - exactKeys(source, ['kind', 'path', 'lines', 'revision', 'symbol', 'commitIndex', 'extractor', 'contentHash', 'rawExcerpt'], `Intent ${record.id}: source`); - enumValue(source.kind, SOURCE_KINDS, `Intent ${record.id}: source.kind`); - nullableString(source.path, `Intent ${record.id}: source.path`); - nullableString(source.revision, `Intent ${record.id}: source.revision`); - nullableString(source.symbol, `Intent ${record.id}: source.symbol`); - nullableString(source.rawExcerpt, `Intent ${record.id}: source.rawExcerpt`); - nonEmptyString(source.extractor, `Intent ${record.id}: source.extractor`); - if (typeof source.contentHash !== 'string' || !FINGERPRINT.test(source.contentHash)) { - throw new Error(`Intent ${record.id}: source.contentHash must be SHA-256`); - } - if (source.commitIndex !== null && (!Number.isInteger(source.commitIndex) || (source.commitIndex as number) < 1)) { - throw new Error(`Intent ${record.id}: source.commitIndex must be null or an integer >= 1`); - } - if (source.lines !== null) { - const lines = objectValue(source.lines, `Intent ${record.id}: source.lines`); - exactKeys(lines, ['start', 'end'], `Intent ${record.id}: source.lines`); - if (!Number.isInteger(lines.start) || (lines.start as number) < 1 || !Number.isInteger(lines.end) || (lines.end as number) < (lines.start as number)) { - throw new Error(`Intent ${record.id}: source.lines must be positive and end >= start`); - } - } - - const epistemic = objectValue(record.epistemic, `Intent ${record.id}: epistemic`); - exactKeys(epistemic, ['class', 'confidence', 'basis'], `Intent ${record.id}: epistemic`); - enumValue(epistemic.class, EPISTEMIC_CLASSES, `Intent ${record.id}: epistemic.class`); - if (typeof epistemic.confidence !== 'number' || !Number.isFinite(epistemic.confidence) - || epistemic.confidence < 0 || epistemic.confidence > 1) { - throw new Error(`Intent ${record.id}: epistemic.confidence must be between 0 and 1`); - } - stringArray(epistemic.basis, `Intent ${record.id}: epistemic.basis`, true); - nullableDate(record.observedAt, `Intent ${record.id}: observedAt`); - - const metadata = objectValue(record.metadata, `Intent ${record.id}: metadata`); - if (!isJsonValue(metadata)) throw new Error(`Intent ${record.id}: metadata must contain JSON values only`); - assertIntentGenerationMetadata(metadata.generation, `Intent ${record.id}: metadata.generation`); - assertGenerationMatchesExtractor(metadata.generation, source.extractor as string, `Intent ${record.id}: metadata.generation`); - if (epistemic.class === 'llm_inference' - && (metadata.generation as { used: unknown }).used !== 'llm') { - throw new Error(`Intent ${record.id}: llm_inference requires metadata.generation.used=llm`); - } -} - -function assertGenerationMatchesExtractor(value: unknown, extractor: string, name: string): void { - const generation = value as { generator: string; generatorVersion: string }; - const separator = extractor.lastIndexOf('@'); - const expectedGenerator = separator > 0 ? extractor.slice(0, separator) : extractor; - if (generation.generator !== expectedGenerator) { - throw new Error(`${name}.generator must match source.extractor (${expectedGenerator})`); - } - if (separator > 0 && generation.generatorVersion !== extractor.slice(separator + 1)) { - throw new Error(`${name}.generatorVersion must match source.extractor (${extractor.slice(separator + 1)})`); - } -} - -function assertIntentGenerationMetadata(value: unknown, name: string): void { - const generation = objectValue(value, name); - exactKeys(generation, [ - 'generator', 'generatorVersion', 'runtimeVersion', 'requested', 'used', 'degraded', - 'fallbackReason', 'provider', 'model', 'responseId', - ], name); - nonBlankString(generation.generator, `${name}.generator`); - nonBlankString(generation.generatorVersion, `${name}.generatorVersion`); - if (typeof generation.runtimeVersion !== 'string' || !RUNTIME_VERSION.test(generation.runtimeVersion)) { - throw new Error(`${name}.runtimeVersion must be a semantic version`); - } - enumValue(generation.requested, GENERATION_EFFECTIVE_MODES, `${name}.requested`); - enumValue(generation.used, GENERATION_EFFECTIVE_MODES, `${name}.used`); - if (typeof generation.degraded !== 'boolean') throw new Error(`${name}.degraded must be a boolean`); - nullableString(generation.fallbackReason, `${name}.fallbackReason`); - nullableString(generation.provider, `${name}.provider`); - nullableString(generation.model, `${name}.model`); - nullableString(generation.responseId, `${name}.responseId`); - if (generation.used === 'llm') { - nonBlankString(generation.provider, `${name}.provider`); - nonBlankString(generation.model, `${name}.model`); - } else if (generation.provider !== null || generation.model !== null || generation.responseId !== null) { - throw new Error(`${name}: deterministic generation cannot claim an LLM provider, model or responseId`); - } - if (generation.degraded) { - if (generation.requested !== 'llm' || generation.used !== 'deterministic') { - throw new Error(`${name}: degraded generation must be an LLM request using deterministic fallback`); - } - nonBlankString(generation.fallbackReason, `${name}.fallbackReason`); - } else if (generation.fallbackReason !== null) { - throw new Error(`${name}.fallbackReason must be null when generation is not degraded`); - } -} - -export function assertIntentRecords(values: unknown): asserts values is IntentRecord[] { - if (!Array.isArray(values)) throw new Error('Intent records must be an array'); - values.forEach(assertIntentRecord); -} - -export function assertIntentGraph(value: unknown): asserts value is IntentGraph { - const graph = objectValue(value, 'Intent graph'); - exactKeys(graph, ['schemaVersion', 'generatedAt', 'fingerprint', 'records', 'relations', 'stats'], 'Intent graph'); - if (graph.schemaVersion !== 't2c.graph/v1') throw new Error('Unsupported graph schemaVersion'); - dateString(graph.generatedAt, 'Graph generatedAt'); - fingerprint(graph.fingerprint, 'Graph fingerprint'); - assertIntentRecords(graph.records); - if (!Array.isArray(graph.relations)) throw new Error('Graph relations must be an array'); - const recordIds = new Set((graph.records as IntentRecord[]).map((record) => record.id)); - if (recordIds.size !== (graph.records as IntentRecord[]).length) throw new Error('Graph record IDs must be unique'); - const relationIds = new Set(); - for (const relation of graph.relations) { - assertRelation(relation, recordIds); - if (relationIds.has((relation as IntentRelation).id)) throw new Error(`Duplicate relation id: ${(relation as IntentRelation).id}`); - relationIds.add((relation as IntentRelation).id); - } - const stats = objectValue(graph.stats, 'Graph stats'); - exactKeys(stats, ['bySource', 'byAction', 'byStatus'], 'Graph stats'); - countMap(stats.bySource, 'Graph stats.bySource'); - countMap(stats.byAction, 'Graph stats.byAction'); - countMap(stats.byStatus, 'Graph stats.byStatus'); - const records = graph.records as IntentRecord[]; - exactCounts(stats.bySource, countRecords(records, (record) => record.source.kind), 'Graph stats.bySource'); - exactCounts(stats.byAction, countRecords(records, (record) => record.statement.action), 'Graph stats.byAction'); - exactCounts(stats.byStatus, countRecords(records, (record) => record.lifecycle.status), 'Graph stats.byStatus'); - const expectedFingerprint = graphFingerprint(records, graph.relations as IntentRelation[]); - if (graph.fingerprint !== expectedFingerprint) throw new Error('Graph fingerprint does not match records and relations'); -} - -export function assertIntentGraphDiff(value: unknown): asserts value is IntentGraphDiff { - const diff = objectValue(value, 'Intent graph diff'); - exactKeys(diff, ['schemaVersion', 'generatedAt', 'fingerprint', 'beforeFingerprint', 'afterFingerprint', 'records', 'relations', 'summary'], 'Intent graph diff'); - if (diff.schemaVersion !== 't2c.diff/v1') throw new Error('Unsupported graph diff schemaVersion'); - dateString(diff.generatedAt, 'Graph diff generatedAt'); - fingerprint(diff.fingerprint, 'Graph diff fingerprint'); - fingerprint(diff.beforeFingerprint, 'Graph diff beforeFingerprint'); - fingerprint(diff.afterFingerprint, 'Graph diff afterFingerprint'); - - const records = objectValue(diff.records, 'Graph diff records'); - exactKeys(records, ['added', 'removed', 'changed', 'unchanged'], 'Graph diff records'); - assertIntentRecords(records.added); - assertIntentRecords(records.removed); - if (!Array.isArray(records.changed)) throw new Error('Graph diff changed records must be an array'); - for (const rawChange of records.changed) { - const change = objectValue(rawChange, 'Graph diff record change'); - exactKeys(change, ['identity', 'before', 'after', 'changedFields'], 'Graph diff record change'); - nonEmptyString(change.identity, 'Graph diff record change identity'); - assertIntentRecord(change.before); - assertIntentRecord(change.after); - stringArray(change.changedFields, 'Graph diff changedFields', true); - } - nonNegativeInteger(records.unchanged, 'Graph diff records.unchanged'); - - const relations = objectValue(diff.relations, 'Graph diff relations'); - exactKeys(relations, ['added', 'removed', 'unchanged'], 'Graph diff relations'); - if (!Array.isArray(relations.added) || !Array.isArray(relations.removed)) throw new Error('Graph diff relation sets must be arrays'); - [...relations.added, ...relations.removed].forEach((relation) => assertRelation(relation)); - nonNegativeInteger(relations.unchanged, 'Graph diff relations.unchanged'); - - const summary = objectValue(diff.summary, 'Graph diff summary'); - exactKeys(summary, ['recordsAdded', 'recordsRemoved', 'recordsChanged', 'recordsUnchanged', 'relationsAdded', 'relationsRemoved', 'relationsUnchanged'], 'Graph diff summary'); - for (const [key, count] of Object.entries(summary)) nonNegativeInteger(count, `Graph diff summary.${key}`); - const expectedCounts: Record = { - recordsAdded: (records.added as unknown[]).length, - recordsRemoved: (records.removed as unknown[]).length, - recordsChanged: (records.changed as unknown[]).length, - recordsUnchanged: records.unchanged as number, - relationsAdded: (relations.added as unknown[]).length, - relationsRemoved: (relations.removed as unknown[]).length, - relationsUnchanged: relations.unchanged as number, - }; - exactCounts(summary, expectedCounts, 'Graph diff summary'); -} - -export function assertConclusion( - value: unknown, - context: GroundedValidationContext, -): asserts value is Conclusion { - const known = validateGroundedContext(context); - assertConclusionValue(value, known.recordIds, known.diagnosticIds); -} - -export function assertConclusions( - values: unknown, - context: GroundedValidationContext, -): asserts values is Conclusion[] { - if (!Array.isArray(values)) throw new Error('Conclusions must be an array'); - const known = validateGroundedContext(context); - const ids = new Set(); - for (const value of values) { - assertConclusionValue(value, known.recordIds, known.diagnosticIds); - const id = (value as Conclusion).id; - if (ids.has(id)) throw new Error(`Duplicate conclusion id: ${id}`); - ids.add(id); - } -} - -export function assertTodoProposal( - value: unknown, - context: TodoProposalValidationContext, -): asserts value is TodoProposal { - const known = validateTodoProposalContext(context); - assertTodoProposalValue(value, known.recordIds, known.diagnosticIds, known.conclusionIds); -} - -export function assertTodoProposals( - values: unknown, - context: TodoProposalValidationContext, -): asserts values is TodoProposal[] { - if (!Array.isArray(values)) throw new Error('TODO proposals must be an array'); - const known = validateTodoProposalContext(context); - const proposalIds = new Set(); - for (const value of values) { - assertTodoProposalValue(value, known.recordIds, known.diagnosticIds, known.conclusionIds); - const id = (value as TodoProposal).id; - if (proposalIds.has(id)) throw new Error(`Duplicate TODO proposal id: ${id}`); - proposalIds.add(id); - } - for (const proposal of values as TodoProposal[]) { - for (const dependency of proposal.dependencies) { - if (!proposalIds.has(dependency)) { - throw new Error(`TODO proposal ${proposal.id} references unknown dependency ${dependency}`); - } - } - } - assertAcyclicProposalDependencies(values as TodoProposal[]); -} - -export function assertCodeChangePlan( - value: unknown, - context: CodeChangePlanValidationContext, -): asserts value is CodeChangePlan { - const known = validateCodeChangePlanContext(context); - assertCodeChangePlanValue(value, known); - assertPlanGraphFingerprint(value, context.graph.fingerprint); -} - -export function assertCodeChangePlans( - values: unknown, - context: CodeChangePlanValidationContext, -): asserts values is CodeChangePlan[] { - if (!Array.isArray(values)) throw new Error('Code change plans must be an array'); - const known = validateCodeChangePlanContext(context); - const ids = new Set(); - for (const value of values) { - assertCodeChangePlanValue(value, known); - assertPlanGraphFingerprint(value, context.graph.fingerprint); - const id = (value as CodeChangePlan).id; - if (ids.has(id)) throw new Error(`Duplicate code change plan id: ${id}`); - ids.add(id); - } -} - -/** - * Validate persisted plans for rendering when their source graph and - * diagnostics are not loaded. Evidence references remain syntax-checked and - * content-bound by each plan hash; the supplied graph fingerprint must match. - */ -export function assertCodeChangePlansForReview( - values: unknown, - graphFingerprintValue: string, -): asserts values is CodeChangePlan[] { - if (!Array.isArray(values)) throw new Error('Code change plans must be an array'); - fingerprint(graphFingerprintValue, 'Code change review graphFingerprint'); - const ids = new Set(); - for (const value of values) { - const plan = objectValue(value, 'Code change plan'); - const evidence = objectValue(plan.evidence, 'Code change plan evidence'); - uniqueIdArray(evidence.recordIds, RECORD_ID, 'Code change plan evidence.recordIds'); - uniqueIdArray(evidence.diagnosticIds, DIAGNOSTIC_ID, 'Code change plan evidence.diagnosticIds'); - uniqueIdArray(evidence.conclusionIds, CONCLUSION_ID, 'Code change plan evidence.conclusionIds'); - uniqueIdArray(evidence.proposalIds, TODO_PROPOSAL_ID, 'Code change plan evidence.proposalIds'); - assertCodeChangePlanValue(value, { - recordIds: new Set(evidence.recordIds as string[]), - diagnosticIds: new Set(evidence.diagnosticIds as string[]), - conclusionIds: new Set(evidence.conclusionIds as string[]), - proposalIds: new Set(evidence.proposalIds as string[]), - }); - assertPlanGraphFingerprint(value, graphFingerprintValue); - const id = (value as CodeChangePlan).id; - if (ids.has(id)) throw new Error(`Duplicate code change plan id: ${id}`); - ids.add(id); - } -} - -/** - * Validate a persisted plan before acceptance when its full conclusion and - * TODO-proposal objects are no longer present. Their IDs remain syntax-checked - * and content-bound by the plan hash; records and diagnostics stay grounded in - * the supplied before graph. - */ -export function assertCodeChangePlanForAcceptance( - value: unknown, - context: GroundedValidationContext, -): asserts value is CodeChangePlan { - const known = validateGroundedContext(context); - const plan = objectValue(value, 'Code change plan'); - const evidence = objectValue(plan.evidence, 'Code change plan evidence'); - uniqueIdArray(evidence.conclusionIds, CONCLUSION_ID, 'Code change plan evidence.conclusionIds'); - uniqueIdArray(evidence.proposalIds, TODO_PROPOSAL_ID, 'Code change plan evidence.proposalIds'); - assertCodeChangePlanValue(value, { - ...known, - conclusionIds: new Set(evidence.conclusionIds as string[]), - proposalIds: new Set(evidence.proposalIds as string[]), - }); - assertPlanGraphFingerprint(value, context.graph.fingerprint); -} - -function assertPlanGraphFingerprint(plan: CodeChangePlan, graphFingerprintValue: string): void { - if (plan.evidence.graphFingerprint !== graphFingerprintValue) { - throw new Error('Code change plan evidence.graphFingerprint does not match its graph'); - } -} - -export function assertCodeChangeAcceptance( - value: unknown, - context: CodeChangeAcceptanceValidationContext, -): asserts value is CodeChangeAcceptance { - assertCodeChangePlanForAcceptance(context.plan, context.before); - const beforeKnown = validateGroundedContext(context.before); - const afterKnown = validateGroundedContext(context.after); - const acceptance = objectValue(value, 'Code change acceptance'); - exactKeys(acceptance, [ - 'schemaVersion', 'planId', 'planHash', 'beforeGraphFingerprint', 'afterGraphFingerprint', - 'beforeDiagnosticIds', 'afterDiagnosticIds', 'clearedDiagnosticIds', 'remainingDiagnosticIds', - 'newBlockingDiagnosticIds', 'accepted', 'reasons', 'evaluatedAt', 'generation', - ], 'Code change acceptance'); - if (acceptance.schemaVersion !== 't2c.code-change-acceptance/v1') { - throw new Error('Unsupported code change acceptance schemaVersion'); - } - if (acceptance.planId !== context.plan.id) throw new Error('Code change acceptance planId does not match its plan'); - if (acceptance.planHash !== context.plan.planHash) throw new Error('Code change acceptance planHash does not match its plan'); - if (acceptance.beforeGraphFingerprint !== context.before.graph.fingerprint) { - throw new Error('Code change acceptance beforeGraphFingerprint does not match its graph'); - } - if (acceptance.afterGraphFingerprint !== context.after.graph.fingerprint) { - throw new Error('Code change acceptance afterGraphFingerprint does not match its graph'); - } - for (const key of [ - 'beforeDiagnosticIds', 'afterDiagnosticIds', 'clearedDiagnosticIds', 'remainingDiagnosticIds', - 'newBlockingDiagnosticIds', - ] as const) { - uniqueIdArray(acceptance[key], DIAGNOSTIC_ID, `Code change acceptance ${key}`); - } - const beforeIds = [...beforeKnown.diagnosticIds].sort(); - const afterIds = [...afterKnown.diagnosticIds].sort(); - const targeted = [...context.plan.evidence.diagnosticIds].sort(); - const expectedCleared = targeted.filter((id) => !afterKnown.diagnosticIds.has(id)); - const expectedRemaining = targeted.filter((id) => afterKnown.diagnosticIds.has(id)); - const expectedBlocking = context.after.diagnostics.diagnostics - .filter((item) => item.severity === 'blocking' && !beforeKnown.diagnosticIds.has(item.id)) - .map((item) => item.id) - .sort(); - exactStringSet(acceptance.beforeDiagnosticIds as string[], beforeIds, 'Code change acceptance beforeDiagnosticIds'); - exactStringSet(acceptance.afterDiagnosticIds as string[], afterIds, 'Code change acceptance afterDiagnosticIds'); - exactStringSet(acceptance.clearedDiagnosticIds as string[], expectedCleared, 'Code change acceptance clearedDiagnosticIds'); - exactStringSet(acceptance.remainingDiagnosticIds as string[], expectedRemaining, 'Code change acceptance remainingDiagnosticIds'); - exactStringSet(acceptance.newBlockingDiagnosticIds as string[], expectedBlocking, 'Code change acceptance newBlockingDiagnosticIds'); - const expectedAccepted = expectedRemaining.length === 0 && expectedBlocking.length === 0; - if (acceptance.accepted !== expectedAccepted) throw new Error('Code change acceptance accepted flag is inconsistent'); - nonEmptyUniqueStringArray(acceptance.reasons, 'Code change acceptance reasons'); - dateString(acceptance.evaluatedAt, 'Code change acceptance evaluatedAt'); - assertGroundedGenerationMetadata(acceptance.generation, 'Code change acceptance generation'); - if ((acceptance.generation as GroundedGenerationMetadata).generatedAt !== acceptance.evaluatedAt) { - throw new Error('Code change acceptance generation.generatedAt must match evaluatedAt'); - } -} - -function assertConclusionValue( - value: unknown, - recordIds: Set, - diagnosticIds: Set, -): asserts value is Conclusion { - const conclusion = objectValue(value, 'Conclusion'); - exactKeys(conclusion, [ - 'schemaVersion', 'id', 'kind', 'title', 'detail', 'severity', 'diagnosticIds', 'recordIds', 'confidence', 'generation', - ], 'Conclusion'); - if (conclusion.schemaVersion !== 't2c.conclusion/v1') throw new Error('Unsupported conclusion schemaVersion'); - if (typeof conclusion.id !== 'string' || !CONCLUSION_ID.test(conclusion.id)) { - throw new Error('Conclusion id must match CONC-<20 hex>'); - } - enumValue(conclusion.kind, CONCLUSION_KINDS, `Conclusion ${conclusion.id}: kind`); - nonBlankString(conclusion.title, `Conclusion ${conclusion.id}: title`); - nonBlankString(conclusion.detail, `Conclusion ${conclusion.id}: detail`); - enumValue(conclusion.severity, DIAGNOSTIC_SEVERITIES, `Conclusion ${conclusion.id}: severity`); - nonEmptyUniqueIdArray(conclusion.diagnosticIds, DIAGNOSTIC_ID, `Conclusion ${conclusion.id}: diagnosticIds`); - nonEmptyUniqueIdArray(conclusion.recordIds, RECORD_ID, `Conclusion ${conclusion.id}: recordIds`); - knownReferences(conclusion.diagnosticIds as string[], diagnosticIds, `Conclusion ${conclusion.id}: diagnosticIds`); - knownReferences(conclusion.recordIds as string[], recordIds, `Conclusion ${conclusion.id}: recordIds`); - confidence(conclusion.confidence, `Conclusion ${conclusion.id}: confidence`); - assertGroundedGenerationMetadata(conclusion.generation, `Conclusion ${conclusion.id}: generation`); - const expectedId = createConclusionId(conclusion as unknown as Conclusion); - if (conclusion.id !== expectedId) throw new Error(`Conclusion id does not match semantic content: expected ${expectedId}`); -} - -function assertTodoProposalValue( - value: unknown, - recordIds: Set, - diagnosticIds: Set, - conclusionIds: Set, -): asserts value is TodoProposal { - const proposal = objectValue(value, 'TODO proposal'); - exactKeys(proposal, [ - 'schemaVersion', 'id', 'title', 'description', 'priority', 'status', 'target', 'acceptanceCriteria', - 'dependencies', 'conclusionIds', 'diagnosticIds', 'recordIds', 'confidence', 'generation', - ], 'TODO proposal'); - if (proposal.schemaVersion !== 't2c.todo-proposal/v1') throw new Error('Unsupported TODO proposal schemaVersion'); - if (typeof proposal.id !== 'string' || !TODO_PROPOSAL_ID.test(proposal.id)) { - throw new Error('TODO proposal id must match TPROP-<20 hex>'); - } - nonBlankString(proposal.title, `TODO proposal ${proposal.id}: title`); - nonBlankString(proposal.description, `TODO proposal ${proposal.id}: description`); - enumValue(proposal.priority, TODO_PRIORITIES, `TODO proposal ${proposal.id}: priority`); - if (proposal.status !== 'proposed') throw new Error(`TODO proposal ${proposal.id}: status must be proposed`); - const target = objectValue(proposal.target, `TODO proposal ${proposal.id}: target`); - exactKeys(target, ['paths', 'symbols', 'tickets', 'versions'], `TODO proposal ${proposal.id}: target`); - for (const key of ['paths', 'symbols', 'tickets', 'versions'] as const) { - stringArray(target[key], `TODO proposal ${proposal.id}: target.${key}`, true); - if ((target[key] as string[]).some((item) => !item.trim())) { - throw new Error(`TODO proposal ${proposal.id}: target.${key} cannot contain blank values`); - } - } - nonEmptyUniqueStringArray(proposal.acceptanceCriteria, `TODO proposal ${proposal.id}: acceptanceCriteria`); - uniqueIdArray(proposal.dependencies, TODO_PROPOSAL_ID, `TODO proposal ${proposal.id}: dependencies`); - if ((proposal.dependencies as string[]).includes(proposal.id as string)) { - throw new Error(`TODO proposal ${proposal.id} cannot depend on itself`); - } - nonEmptyUniqueIdArray(proposal.conclusionIds, CONCLUSION_ID, `TODO proposal ${proposal.id}: conclusionIds`); - nonEmptyUniqueIdArray(proposal.diagnosticIds, DIAGNOSTIC_ID, `TODO proposal ${proposal.id}: diagnosticIds`); - nonEmptyUniqueIdArray(proposal.recordIds, RECORD_ID, `TODO proposal ${proposal.id}: recordIds`); - knownReferences(proposal.conclusionIds as string[], conclusionIds, `TODO proposal ${proposal.id}: conclusionIds`); - knownReferences(proposal.diagnosticIds as string[], diagnosticIds, `TODO proposal ${proposal.id}: diagnosticIds`); - knownReferences(proposal.recordIds as string[], recordIds, `TODO proposal ${proposal.id}: recordIds`); - confidence(proposal.confidence, `TODO proposal ${proposal.id}: confidence`); - assertGroundedGenerationMetadata(proposal.generation, `TODO proposal ${proposal.id}: generation`); - const expectedId = createTodoProposalId(proposal as unknown as TodoProposal); - if (proposal.id !== expectedId) throw new Error(`TODO proposal id does not match semantic content: expected ${expectedId}`); -} - -export function assertGroundedGenerationMetadata(value: unknown, name: string): asserts value is GroundedGenerationMetadata { - const generation = objectValue(value, name); - exactKeys(generation, [ - 'generator', 'generatorVersion', 'runtimeVersion', 'generatedAt', 'requestedMode', 'effectiveMode', - 'degraded', 'model', 'provider', 'responseId', 'configurationFingerprint', 'reason', - ], name); - nonBlankString(generation.generator, `${name}.generator`); - nonBlankString(generation.generatorVersion, `${name}.generatorVersion`); - if (typeof generation.runtimeVersion !== 'string' || !RUNTIME_VERSION.test(generation.runtimeVersion)) { - throw new Error(`${name}.runtimeVersion must be a semantic version`); - } - dateString(generation.generatedAt, `${name}.generatedAt`); - enumValue(generation.requestedMode, GENERATION_REQUESTED_MODES, `${name}.requestedMode`); - enumValue(generation.effectiveMode, GENERATION_EFFECTIVE_MODES, `${name}.effectiveMode`); - if (typeof generation.degraded !== 'boolean') throw new Error(`${name}.degraded must be a boolean`); - nullableString(generation.model, `${name}.model`); - nullableString(generation.provider, `${name}.provider`); - nullableString(generation.responseId, `${name}.responseId`); - fingerprint(generation.configurationFingerprint, `${name}.configurationFingerprint`); - nullableString(generation.reason, `${name}.reason`); - - if (generation.effectiveMode === 'llm') { - nonBlankString(generation.model, `${name}.model`); - nonBlankString(generation.provider, `${name}.provider`); - if (generation.degraded) throw new Error(`${name}.degraded must be false when effectiveMode is llm`); - } - if (generation.requestedMode === 'deterministic') { - if (generation.effectiveMode !== 'deterministic' || generation.degraded - || generation.model !== null || generation.provider !== null || generation.responseId !== null - || generation.reason !== null) { - throw new Error(`${name} deterministic mode cannot contain LLM or degradation metadata`); - } - } - if (generation.requestedMode === 'require-llm' && generation.effectiveMode !== 'llm') { - throw new Error(`${name} require-llm mode cannot use deterministic output`); - } - if (generation.requestedMode === 'prefer-llm' && generation.effectiveMode === 'deterministic' && !generation.degraded) { - throw new Error(`${name} prefer-llm deterministic output must be marked degraded`); - } - if (generation.degraded) { - if (generation.requestedMode !== 'prefer-llm' || generation.effectiveMode !== 'deterministic') { - throw new Error(`${name} degraded output is only valid for prefer-llm deterministic fallback`); - } - nonBlankString(generation.reason, `${name}.reason`); - } else if (generation.reason !== null) { - throw new Error(`${name}.reason must be null when output is not degraded`); - } -} - -function validateGroundedContext(context: GroundedValidationContext): { - recordIds: Set; - diagnosticIds: Set; -} { - assertIntentGraph(context.graph); - const report = objectValue(context.diagnostics, 'Diagnostic report'); - if (report.schemaVersion !== 't2c.diagnostics/v1') throw new Error('Unsupported diagnostic schemaVersion'); - if (report.graphFingerprint !== context.graph.fingerprint) { - throw new Error('Diagnostic report does not describe the supplied graph'); - } - if (!Array.isArray(report.diagnostics)) throw new Error('Diagnostic report diagnostics must be an array'); - const diagnosticIds = new Set(); - for (const value of report.diagnostics) { - const diagnostic = objectValue(value, 'Diagnostic'); - if (typeof diagnostic.id !== 'string' || !DIAGNOSTIC_ID.test(diagnostic.id)) { - throw new Error('Diagnostic id must match DIAG-<20 hex>'); - } - if (diagnosticIds.has(diagnostic.id)) throw new Error(`Duplicate diagnostic id: ${diagnostic.id}`); - diagnosticIds.add(diagnostic.id); - } - return { - recordIds: new Set(context.graph.records.map((record) => record.id)), - diagnosticIds, - }; -} - -function validateTodoProposalContext(context: TodoProposalValidationContext): { - recordIds: Set; - diagnosticIds: Set; - conclusionIds: Set; -} { - const known = validateGroundedContext(context); - assertConclusions(context.conclusions, context); - return { - ...known, - conclusionIds: new Set(context.conclusions.map((conclusion) => conclusion.id)), - }; -} - -function validateCodeChangePlanContext(context: CodeChangePlanValidationContext): { - recordIds: Set; - diagnosticIds: Set; - conclusionIds: Set; - proposalIds: Set; -} { - const known = validateGroundedContext(context); - const conclusions = context.conclusions ?? []; - const proposals = context.proposals ?? []; - if (conclusions.length) assertConclusions(conclusions, context); - // Full proposal contracts need conclusions. When only proposal IDs are - // supplied as evidence references, accept the IDs after a light shape check. - if (proposals.length && conclusions.length) { - assertTodoProposals(proposals, { graph: context.graph, diagnostics: context.diagnostics, conclusions }); - } else if (proposals.length) { - const referencedConclusionIds = new Set(); - for (const [index, value] of proposals.entries()) { - const proposal = objectValue(value, `TODO proposal reference[${index}]`); - uniqueIdArray(proposal.conclusionIds, CONCLUSION_ID, `TODO proposal reference[${index}].conclusionIds`); - for (const id of proposal.conclusionIds as string[]) referencedConclusionIds.add(id); - } - const proposalIds = new Set(); - for (const proposal of proposals) { - assertTodoProposalValue( - proposal, - known.recordIds, - known.diagnosticIds, - referencedConclusionIds, - ); - if (proposalIds.has(proposal.id)) throw new Error(`Duplicate TODO proposal id: ${proposal.id}`); - proposalIds.add(proposal.id); - } - } - return { - ...known, - conclusionIds: new Set(conclusions.map((item) => item.id)), - proposalIds: new Set(proposals.map((item) => item.id)), - }; -} - -function assertCodeChangePlanValue( - value: unknown, - known: { - recordIds: Set; - diagnosticIds: Set; - conclusionIds: Set; - proposalIds: Set; - }, -): asserts value is CodeChangePlan { - const plan = objectValue(value, 'Code change plan'); - exactKeys(plan, [ - 'schemaVersion', 'id', 'planHash', 'status', 'createdAt', 'title', 'description', 'priority', - 'target', 'acceptanceCriteria', 'changes', 'risk', 'rollback', 'evidence', 'confidence', 'generation', - ], 'Code change plan'); - if (plan.schemaVersion !== 't2c.code-change-plan/v1') { - throw new Error('Unsupported code change plan schemaVersion'); - } - if (typeof plan.id !== 'string' || !CODE_CHANGE_PLAN_ID.test(plan.id)) { - throw new Error('Code change plan id must match CPLAN-<20 hex>'); - } - fingerprint(plan.planHash, `Code change plan ${plan.id}: planHash`); - if (plan.status !== 'proposed') throw new Error(`Code change plan ${plan.id}: status must be proposed`); - dateString(plan.createdAt, `Code change plan ${plan.id}: createdAt`); - nonBlankString(plan.title, `Code change plan ${plan.id}: title`); - nonBlankString(plan.description, `Code change plan ${plan.id}: description`); - enumValue(plan.priority, TODO_PRIORITIES, `Code change plan ${plan.id}: priority`); - const target = objectValue(plan.target, `Code change plan ${plan.id}: target`); - exactKeys(target, ['paths', 'symbols', 'tickets', 'versions'], `Code change plan ${plan.id}: target`); - for (const key of ['paths', 'symbols', 'tickets', 'versions'] as const) { - stringArray(target[key], `Code change plan ${plan.id}: target.${key}`, true); - if ((target[key] as string[]).some((item) => !item.trim())) { - throw new Error(`Code change plan ${plan.id}: target.${key} cannot contain blank values`); - } - } - const targetPaths = new Set((target.paths as string[]).map((item, index) => ( - repositoryPath(item, `Code change plan ${plan.id}: target.paths[${index}]`) - ))); - nonEmptyUniqueStringArray(plan.acceptanceCriteria, `Code change plan ${plan.id}: acceptanceCriteria`); - if (!Array.isArray(plan.changes) || plan.changes.length === 0) { - throw new Error(`Code change plan ${plan.id}: changes must be a non-empty array`); - } - const changePaths = new Set(); - for (const [index, rawChange] of plan.changes.entries()) { - const change = objectValue(rawChange, `Code change plan ${plan.id}: changes[${index}]`); - exactKeys(change, ['path', 'action', 'symbols', 'rationale'], `Code change plan ${plan.id}: changes[${index}]`); - nonBlankString(change.path, `Code change plan ${plan.id}: changes[${index}].path`); - const normalizedPath = repositoryPath(change.path, `Code change plan ${plan.id}: changes[${index}].path`); - if (!targetPaths.has(normalizedPath)) { - throw new Error(`Code change plan ${plan.id}: changes[${index}].path is not present in target.paths`); - } - enumValue(change.action, CODE_CHANGE_ACTIONS, `Code change plan ${plan.id}: changes[${index}].action`); - stringArray(change.symbols, `Code change plan ${plan.id}: changes[${index}].symbols`, true); - if ((change.symbols as string[]).some((item) => !item.trim())) { - throw new Error(`Code change plan ${plan.id}: changes[${index}].symbols cannot contain blank values`); - } - nonBlankString(change.rationale, `Code change plan ${plan.id}: changes[${index}].rationale`); - if (changePaths.has(normalizedPath)) { - throw new Error(`Code change plan ${plan.id}: duplicate change for ${normalizedPath}`); - } - changePaths.add(normalizedPath); - } - const risk = objectValue(plan.risk, `Code change plan ${plan.id}: risk`); - exactKeys(risk, ['level', 'reasons'], `Code change plan ${plan.id}: risk`); - enumValue(risk.level, CODE_CHANGE_RISK_LEVELS, `Code change plan ${plan.id}: risk.level`); - nonEmptyUniqueStringArray(risk.reasons, `Code change plan ${plan.id}: risk.reasons`); - nonBlankString(plan.rollback, `Code change plan ${plan.id}: rollback`); - const evidence = objectValue(plan.evidence, `Code change plan ${plan.id}: evidence`); - exactKeys(evidence, [ - 'graphFingerprint', 'recordIds', 'diagnosticIds', 'conclusionIds', 'proposalIds', - ], `Code change plan ${plan.id}: evidence`); - fingerprint(evidence.graphFingerprint, `Code change plan ${plan.id}: evidence.graphFingerprint`); - nonEmptyUniqueIdArray(evidence.recordIds, RECORD_ID, `Code change plan ${plan.id}: evidence.recordIds`); - nonEmptyUniqueIdArray(evidence.diagnosticIds, DIAGNOSTIC_ID, `Code change plan ${plan.id}: evidence.diagnosticIds`); - uniqueIdArray(evidence.conclusionIds, CONCLUSION_ID, `Code change plan ${plan.id}: evidence.conclusionIds`); - uniqueIdArray(evidence.proposalIds, TODO_PROPOSAL_ID, `Code change plan ${plan.id}: evidence.proposalIds`); - knownReferences(evidence.recordIds as string[], known.recordIds, `Code change plan ${plan.id}: evidence.recordIds`); - knownReferences(evidence.diagnosticIds as string[], known.diagnosticIds, `Code change plan ${plan.id}: evidence.diagnosticIds`); - knownReferences(evidence.conclusionIds as string[], known.conclusionIds, `Code change plan ${plan.id}: evidence.conclusionIds`); - knownReferences(evidence.proposalIds as string[], known.proposalIds, `Code change plan ${plan.id}: evidence.proposalIds`); - confidence(plan.confidence, `Code change plan ${plan.id}: confidence`); - assertGroundedGenerationMetadata(plan.generation, `Code change plan ${plan.id}: generation`); - - const semantic = plan as unknown as CodeChangePlan; - const expectedHash = createCodeChangePlanHash(semantic); - if (plan.planHash !== expectedHash) { - throw new Error(`Code change plan planHash does not match semantic content: expected ${expectedHash}`); - } - const expectedId = createCodeChangePlanId(semantic); - if (plan.id !== expectedId) { - throw new Error(`Code change plan id does not match semantic content: expected ${expectedId}`); - } -} - -function assertRelation(value: unknown, knownRecords?: Set): asserts value is IntentRelation { - const relation = objectValue(value, 'Intent relation'); - exactKeys(relation, ['id', 'from', 'to', 'type', 'confidence', 'basis'], 'Intent relation'); - if (typeof relation.id !== 'string' || !RELATION_ID.test(relation.id)) throw new Error('Intent relation id must match REL-<20 hex>'); - nonEmptyString(relation.from, `Relation ${relation.id}: from`); - nonEmptyString(relation.to, `Relation ${relation.id}: to`); - enumValue(relation.type, RELATION_TYPES, `Relation ${relation.id}: type`); - if (typeof relation.confidence !== 'number' || !Number.isFinite(relation.confidence) - || relation.confidence < 0 || relation.confidence > 1) { - throw new Error(`Relation ${relation.id}: confidence must be between 0 and 1`); - } - stringArray(relation.basis, `Relation ${relation.id}: basis`, true); - if (knownRecords && (!knownRecords.has(relation.from as string) || !knownRecords.has(relation.to as string))) { - throw new Error(`Relation ${relation.id} references unknown records`); - } -} - -function objectValue(value: unknown, name: string): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${name} must be an object`); - return value as Record; -} - -function exactKeys(value: Record, expected: string[], name: string): void { - const expectedSet = new Set(expected); - const missing = expected.filter((key) => !(key in value)); - const extra = Object.keys(value).filter((key) => !expectedSet.has(key)); - if (missing.length) throw new Error(`${name} is missing: ${missing.join(', ')}`); - if (extra.length) throw new Error(`${name} has unsupported fields: ${extra.join(', ')}`); -} - -function nonEmptyString(value: unknown, name: string): asserts value is string { - if (typeof value !== 'string' || !value.length) throw new Error(`${name} must be a non-empty string`); -} - -function nonBlankString(value: unknown, name: string): asserts value is string { - if (typeof value !== 'string' || !value.trim().length) throw new Error(`${name} must be a non-blank string`); -} - -function nullableString(value: unknown, name: string): void { - if (value !== null && typeof value !== 'string') throw new Error(`${name} must be a string or null`); -} - -function enumValue(value: unknown, allowed: Set, name: string): asserts value is string { - if (typeof value !== 'string' || !allowed.has(value)) throw new Error(`${name} has unsupported value: ${String(value)}`); -} - -function stringArray(value: unknown, name: string, unique = false): asserts value is string[] { - if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) throw new Error(`${name} must be an array of strings`); - if (unique && new Set(value).size !== value.length) throw new Error(`${name} must contain unique values`); -} - -function nonEmptyUniqueStringArray(value: unknown, name: string): asserts value is string[] { - stringArray(value, name, true); - if (!value.length || value.some((item) => !item.trim().length)) { - throw new Error(`${name} must contain at least one non-blank string`); - } - if (new Set(value.map((item) => item.trim())).size !== value.length) { - throw new Error(`${name} must remain unique after trimming whitespace`); - } -} - -function repositoryPath(value: unknown, name: string): string { - nonBlankString(value, name); - const normalized = value.trim().replace(/\\/g, '/'); - if (normalized.startsWith('/') || normalized.split('/').some((part) => part === '..')) { - throw new Error(`${name} must be a relative repository path without parent traversal`); - } - return normalized; -} - -function exactStringSet(actual: string[], expected: string[], name: string): void { - const normalizedActual = [...actual].sort(); - if (normalizedActual.length !== expected.length - || normalizedActual.some((value, index) => value !== expected[index])) { - throw new Error(`${name} does not match the grounded diagnostic set`); - } -} - -function uniqueIdArray(value: unknown, pattern: RegExp, name: string): asserts value is string[] { - stringArray(value, name, true); - if (value.some((item) => !pattern.test(item))) throw new Error(`${name} contains an invalid id`); -} - -function nonEmptyUniqueIdArray(value: unknown, pattern: RegExp, name: string): asserts value is string[] { - uniqueIdArray(value, pattern, name); - if (!value.length) throw new Error(`${name} must contain at least one id`); -} - -function knownReferences(values: string[], known: Set, name: string): void { - const unknown = values.filter((value) => !known.has(value)); - if (unknown.length) throw new Error(`${name} references unknown ids: ${unknown.join(', ')}`); -} - -function confidence(value: unknown, name: string): asserts value is number { - if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) { - throw new Error(`${name} must be between 0 and 1`); - } -} - -function assertAcyclicProposalDependencies(proposals: TodoProposal[]): void { - const byId = new Map(proposals.map((proposal) => [proposal.id, proposal])); - const visiting = new Set(); - const visited = new Set(); - const visit = (id: string, chain: string[]): void => { - if (visiting.has(id)) { - const start = chain.indexOf(id); - throw new Error(`TODO proposal dependency cycle: ${[...chain.slice(Math.max(0, start)), id].join(' -> ')}`); - } - if (visited.has(id)) return; - visiting.add(id); - for (const dependency of byId.get(id)?.dependencies ?? []) visit(dependency, [...chain, id]); - visiting.delete(id); - visited.add(id); - }; - for (const proposal of proposals) visit(proposal.id, []); -} - -function dateString(value: unknown, name: string): asserts value is string { - if (typeof value !== 'string' || !ISO_DATE_TIME.test(value) || !Number.isFinite(Date.parse(value))) { - throw new Error(`${name} must be an ISO date-time string`); - } -} - -function nullableDate(value: unknown, name: string): void { - if (value !== null) dateString(value, name); -} - -function fingerprint(value: unknown, name: string): void { - if (typeof value !== 'string' || !FINGERPRINT.test(value)) throw new Error(`${name} must be SHA-256`); -} - -function nonNegativeInteger(value: unknown, name: string): void { - if (!Number.isInteger(value) || (value as number) < 0) throw new Error(`${name} must be an integer >= 0`); -} - -function countMap(value: unknown, name: string): void { - const map = objectValue(value, name); - for (const [key, count] of Object.entries(map)) { - if (!key) throw new Error(`${name} keys must be non-empty`); - nonNegativeInteger(count, `${name}.${key}`); - } -} - -function countRecords(records: IntentRecord[], selector: (record: IntentRecord) => string): Record { - const counts: Record = {}; - for (const record of records) { - const key = selector(record); - counts[key] = (counts[key] ?? 0) + 1; - } - return counts; -} - -function exactCounts(value: unknown, expected: Record, name: string): void { - const actual = objectValue(value, name); - const keys = [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort(); - for (const key of keys) { - if (actual[key] !== expected[key]) { - throw new Error(`${name} is inconsistent for ${key}: expected ${expected[key] ?? 0}, received ${String(actual[key] ?? 0)}`); - } - } -} - -function isJsonValue(value: unknown): value is JsonValue { - if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; - if (typeof value === 'number') return Number.isFinite(value); - if (Array.isArray(value)) return value.every(isJsonValue); - if (value && typeof value === 'object') return Object.values(value as Record).every(isJsonValue); - return false; -} +export type { + GroundedValidationContext, + TodoProposalValidationContext, + CodeChangePlanValidationContext, + CodeChangeAcceptanceValidationContext, +} from './schema-validation.js'; + +export { + assertIntentRecord, + assertIntentRecords, + assertIntentGraph, + assertIntentGraphDiff, + assertConclusion, + assertConclusions, + assertTodoProposal, + assertTodoProposals, + assertCodeChangePlan, + assertCodeChangePlans, + assertCodeChangePlansForReview, + assertCodeChangePlanForAcceptance, + assertCodeChangeAcceptance, + assertGroundedGenerationMetadata, +} from './schema-validation.js'; diff --git a/src/core/text.ts b/src/core/text.ts index 6c6dd55c..c10a3a2b 100644 --- a/src/core/text.ts +++ b/src/core/text.ts @@ -437,6 +437,27 @@ export function extractVersions(text: string): string[] { return [...new Set(text.match(/\bv?\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?\b/g) ?? [])].sort(); } +const OBJECT_ACTION_WORDS: Record = { + add: /\b(add|create|implement|introduce|build|utworzy(?:ć|c)|doda(?:ć|c)|zaimplementowa(?:ć|c)|stworzy(?:ć|c)|zbudowa(?:ć|c))\b/i, + fix: /\b(fix|repair|correct|napraw(?:ić|ic)|popraw(?:ić|ic))\b/i, + remove: /\b(remove|delete|drop|usun(?:ąć|ac)|wycofa(?:ć|c))\b/i, + refactor: /\b(refactor|restructure|przebudowa(?:ć|c)|refaktoryz)\w*/i, + test: /\b(test|przetestowa(?:ć|c)|testowa(?:ć|c))\b/i, + document: /\b(document|udokumentowa(?:ć|c))\b/i, + configure: /\b(configur\w*|setup|ustawi(?:ć|c)|konfigurowa(?:ć|c))\b/i, + analyze: /\b(analy[sz]\w*|inspect|scan|analizowa(?:ć|c)|zbada(?:ć|c))\b/i, + validate: /\b(validat\w*|verify|check|walidowa(?:ć|c)|sprawdzi(?:ć|c))\b/i, + call: /\b(call|wywoł)\w*/i, + depend_on: /\b(depends?|zależ)\w*/i, + declare: /\b(declare|deklar)\w*/i, + release: /\b(release|wydaj)\w*/i, + change: /\b(change|update|modify|zmieni(?:ć|c)|aktualizowa(?:ć|c)|modyfikowa(?:ć|c))\b/i, + preserve: /\b(preserve|keep|maintain|zachowa(?:ć|c)|utrzyma(?:ć|c))\b/i, + block: /\b(block|deny|prevent|zablokowa(?:ć|c)|zabroni(?:ć|c))\b/i, + approve: /\b(approve|accept|zatwierdzi(?:ć|c)|zaakceptowa(?:ć|c))\b/i, + unknown: /$a/, +}; + export function inferObject(text: string, action: IntentAction): string { const normalized = text .replace(/^\s*[-*+]\s+/, '') @@ -445,27 +466,7 @@ export function inferObject(text: string, action: IntentAction): string { .replace(/^\s*(feat|fix|refactor|test|docs|chore|build|ci|perf)(?:\([^)]*\))?!?:\s*/i, '') .trim(); - const actionWords: Record = { - add: /\b(add|create|implement|introduce|build|utworzy(?:ć|c)|doda(?:ć|c)|zaimplementowa(?:ć|c)|stworzy(?:ć|c)|zbudowa(?:ć|c))\b/i, - fix: /\b(fix|repair|correct|napraw(?:ić|ic)|popraw(?:ić|ic))\b/i, - remove: /\b(remove|delete|drop|usun(?:ąć|ac)|wycofa(?:ć|c))\b/i, - refactor: /\b(refactor|restructure|przebudowa(?:ć|c)|refaktoryz)\w*/i, - test: /\b(test|przetestowa(?:ć|c)|testowa(?:ć|c))\b/i, - document: /\b(document|udokumentowa(?:ć|c))\b/i, - configure: /\b(configur\w*|setup|ustawi(?:ć|c)|konfigurowa(?:ć|c))\b/i, - analyze: /\b(analy[sz]\w*|inspect|scan|analizowa(?:ć|c)|zbada(?:ć|c))\b/i, - validate: /\b(validat\w*|verify|check|walidowa(?:ć|c)|sprawdzi(?:ć|c))\b/i, - call: /\b(call|wywoł)\w*/i, - depend_on: /\b(depends?|zależ)\w*/i, - declare: /\b(declare|deklar)\w*/i, - release: /\b(release|wydaj)\w*/i, - change: /\b(change|update|modify|zmieni(?:ć|c)|aktualizowa(?:ć|c)|modyfikowa(?:ć|c))\b/i, - preserve: /\b(preserve|keep|maintain|zachowa(?:ć|c)|utrzyma(?:ć|c))\b/i, - block: /\b(block|deny|prevent|zablokowa(?:ć|c)|zabroni(?:ć|c))\b/i, - approve: /\b(approve|accept|zatwierdzi(?:ć|c)|zaakceptowa(?:ć|c))\b/i, - unknown: /$a/, - }; - const result = normalized.replace(actionWords[action], '').replace(/^\s*(to|aby|żeby)\s+/i, '').trim(); + const result = normalized.replace(OBJECT_ACTION_WORDS[action], '').replace(/^\s*(to|aby|żeby)\s+/i, '').trim(); return result || normalized || 'unspecified'; } diff --git a/src/core/types-code-change.ts b/src/core/types-code-change.ts new file mode 100644 index 00000000..6b97464c --- /dev/null +++ b/src/core/types-code-change.ts @@ -0,0 +1,163 @@ +import type { + GroundedGenerationMetadata, + IntentTarget, + TodoPriority, +} from './types-foundation.js'; + +/** + * Grounded proposal to change source files so that a plan/diagnostic can be + * closed by later re-analysis. Status is always `proposed`: the runtime never + * applies a code change and never marks work DONE from this contract alone. + */ +export type CodeChangeFileAction = 'create' | 'modify' | 'delete'; + +export interface CodeChangeFile { + path: string; + action: CodeChangeFileAction; + symbols: string[]; + rationale: string; +} + +export type CodeChangeRiskLevel = 'low' | 'medium' | 'high'; + +export interface CodeChangeRisk { + level: CodeChangeRiskLevel; + reasons: string[]; +} + +export interface CodeChangePlan { + schemaVersion: 't2c.code-change-plan/v1'; + id: string; + planHash: string; + status: 'proposed'; + createdAt: string; + title: string; + description: string; + priority: TodoPriority; + target: IntentTarget; + acceptanceCriteria: string[]; + changes: CodeChangeFile[]; + risk: CodeChangeRisk; + rollback: string; + evidence: { + graphFingerprint: string; + recordIds: string[]; + diagnosticIds: string[]; + conclusionIds: string[]; + proposalIds: string[]; + }; + confidence: number; + generation: GroundedGenerationMetadata; +} + +/** + * Result of re-diagnosing a graph after an attempted implementation. + * Acceptance requires every cited diagnostic to clear and no new blocking + * diagnostics to appear. Remaining non-targeted warnings do not fail the gate. + */ +export interface CodeChangeAcceptance { + schemaVersion: 't2c.code-change-acceptance/v1'; + planId: string; + planHash: string; + beforeGraphFingerprint: string; + afterGraphFingerprint: string; + beforeDiagnosticIds: string[]; + afterDiagnosticIds: string[]; + clearedDiagnosticIds: string[]; + remainingDiagnosticIds: string[]; + newBlockingDiagnosticIds: string[]; + accepted: boolean; + reasons: string[]; + evaluatedAt: string; + generation: GroundedGenerationMetadata; +} + +/** Aggregate, non-authoritative result for closing one or more code-change plans. */ +export interface CodeChangeCloseResult { + schemaVersion: 't2c.code-change-close-result/v1'; + evaluatedAt: string; + graphFingerprintBefore: string; + graphFingerprintAfter: string; + planCount: number; + acceptedCount: number; + rejectedCount: number; + allAccepted: boolean; + acceptances: CodeChangeAcceptance[]; + generation: GroundedGenerationMetadata; +} + +/** + * Reviewable, non-executable projection of code-change plans. + * Carries a content hash of the Markdown so reviewers can detect tampering. + * The runtime never applies this artifact to source files. + */ +export interface CodeChangeReviewPatch { + schemaVersion: 't2c.code-change-review/v1'; + createdAt: string; + graphFingerprint: string; + planIds: string[]; + planHashes: string[]; + renderedPatchHash: string; + generation: GroundedGenerationMetadata; +} + +/** + * Structured source-edit proposal bound to one code-change plan. + * Instructions (and optional unified diffs) may only name paths declared by the + * plan. Application requires an explicit approval hash and never runs by default. + */ +export interface CodeChangeSourceEdit { + path: string; + action: CodeChangeFileAction; + symbols: string[]; + instruction: string; + /** + * Optional unified diff body for the single file. Null for instruction-only + * deterministic proposals. When set, the runtime checks path headers and + * rejects parent traversal / absolute host paths. Apply requires a non-null + * diff for every edit. + */ + unifiedDiff: string | null; +} + +export interface CodeChangeSourcePatch { + schemaVersion: 't2c.code-change-source-patch/v1'; + id: string; + patchHash: string; + status: 'proposed'; + createdAt: string; + planId: string; + planHash: string; + graphFingerprint: string; + diagnosticIds: string[]; + recordIds: string[]; + edits: CodeChangeSourceEdit[]; + acceptanceCriteria: string[]; + generation: GroundedGenerationMetadata; +} + +export interface CodeChangeSourcePatchSet { + schemaVersion: 't2c.code-change-source-patch-set/v1'; + generatedAt: string; + graphFingerprint: string; + patches: CodeChangeSourcePatch[]; + generation: GroundedGenerationMetadata; +} + +export interface CodeChangeSourcePatchApproval { + actor: string; + patchHash: string; +} + +export interface CodeChangeSourceApplyReceipt { + schemaVersion: 't2c.code-change-source-apply-receipt/v1'; + patchId: string; + patchHash: string; + planId: string; + approvedBy: string; + approvedAt: string; + appliedAt: string; + appliedPaths: string[]; + fileHashesAfter: Record; + generation: GroundedGenerationMetadata; +} diff --git a/src/core/types-foundation.ts b/src/core/types-foundation.ts new file mode 100644 index 00000000..71cb6300 --- /dev/null +++ b/src/core/types-foundation.ts @@ -0,0 +1,302 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export type SourceKind = + | 'nl' + | 'git' + | 'ast' + | 'todo' + | 'changelog' + | 'document' + | 'agent_log' + | 'test' + | 'system'; + +export type EpistemicClass = + | 'declaration' + | 'plan' + | 'claim' + | 'fact' + | 'inference' + | 'llm_inference'; + +export type LifecycleStatus = + | 'proposed' + | 'planned' + | 'in_progress' + | 'implemented' + | 'verified' + | 'released' + | 'completed' + | 'blocked' + | 'unknown'; + +export type IntentAction = + | 'add' + | 'fix' + | 'remove' + | 'refactor' + | 'test' + | 'document' + | 'configure' + | 'analyze' + | 'validate' + | 'call' + | 'depend_on' + | 'declare' + | 'release' + | 'change' + | 'preserve' + | 'block' + | 'approve' + | 'unknown'; + +export type Modality = 'required' | 'recommended' | 'optional' | 'observed' | 'claimed' | 'unknown'; +export type Polarity = 'positive' | 'negative'; + +export interface SourceLineRange { + start: number; + end: number; +} + +export interface IntentTarget { + paths: string[]; + symbols: string[]; + tickets: string[]; + versions: string[]; +} + +export interface IntentStatement { + kind: string; + actor: string | null; + action: IntentAction; + subject: string | null; + object: string; + target: IntentTarget; + modality: Modality; + polarity: Polarity; + text: string; +} + +export interface IntentSource { + kind: SourceKind; + path: string | null; + lines: SourceLineRange | null; + revision: string | null; + symbol: string | null; + commitIndex: number | null; + extractor: string; + contentHash: string; + rawExcerpt: string | null; +} + +export interface IntentEpistemic { + class: EpistemicClass; + confidence: number; + basis: string[]; +} + +export interface IntentLifecycle { + status: LifecycleStatus; +} + +export type IntentGenerationMode = 'deterministic' | 'llm'; + +/** + * Runtime-owned provenance of the conversion that materialized one DSL record. + * This is required even for deterministic records: `source` identifies the + * evidence, while `generation` identifies the software or model that converted + * that evidence to Intent DSL. + */ +export interface IntentGenerationMetadata extends Record { + generator: string; + generatorVersion: string; + runtimeVersion: string; + requested: IntentGenerationMode; + used: IntentGenerationMode; + degraded: boolean; + fallbackReason: string | null; + provider: string | null; + model: string | null; + responseId: string | null; +} + +export interface IntentRecordMetadata extends Record { + generation: IntentGenerationMetadata; +} + +export interface IntentRecord { + schemaVersion: 't2c.intent/v1'; + id: string; + statement: IntentStatement; + lifecycle: IntentLifecycle; + source: IntentSource; + epistemic: IntentEpistemic; + observedAt: string | null; + metadata: IntentRecordMetadata; +} + +export type RelationType = + | 'declares' + | 'plans' + | 'implements' + | 'modifies' + | 'tests' + | 'documents' + | 'releases' + | 'depends_on' + | 'blocks' + | 'supersedes' + | 'contradicts' + | 'duplicates' + | 'evidenced_by' + | 'claimed_by' + | 'same_as' + | 'related_to'; + +export interface IntentRelation { + id: string; + from: string; + to: string; + type: RelationType; + confidence: number; + basis: string[]; +} + +export interface IntentGraph { + schemaVersion: 't2c.graph/v1'; + generatedAt: string; + fingerprint: string; + records: IntentRecord[]; + relations: IntentRelation[]; + stats: { + bySource: Record; + byAction: Record; + byStatus: Record; + }; +} + +export interface IntentRecordChange { + identity: string; + before: IntentRecord; + after: IntentRecord; + changedFields: string[]; +} + +export interface IntentGraphDiff { + schemaVersion: 't2c.diff/v1'; + generatedAt: string; + fingerprint: string; + beforeFingerprint: string; + afterFingerprint: string; + records: { + added: IntentRecord[]; + removed: IntentRecord[]; + changed: IntentRecordChange[]; + unchanged: number; + }; + relations: { + added: IntentRelation[]; + removed: IntentRelation[]; + unchanged: number; + }; + summary: { + recordsAdded: number; + recordsRemoved: number; + recordsChanged: number; + recordsUnchanged: number; + relationsAdded: number; + relationsRemoved: number; + relationsUnchanged: number; + }; +} + +export type DiagnosticCode = + | 'ALIGNED' + | 'PLANNED_NOT_IMPLEMENTED' + | 'IMPLEMENTED_NOT_PLANNED' + | 'IMPLEMENTED_NOT_DOCUMENTED' + | 'CHANGELOG_WITHOUT_IMPLEMENTATION' + | 'CONFLICTING_INTENT' + | 'AMBIGUOUS_REQUIREMENT' + | 'UNLINKED_RECORD' + | 'LOW_CONFIDENCE' + | 'INSUFFICIENT_EVIDENCE' + | 'LLM_NOT_CONFIGURED' + | 'SOURCE_UNAVAILABLE' + | 'PARTICIPANT_IDENTITY_UNRESOLVED' + | 'HUMAN_COMMUNICATION_CONFLICT' + | 'AGENT_COMMUNICATION_CONFLICT' + | 'HUMAN_AGENT_CONFLICT' + | 'REQUEST_WITHOUT_AGENT_RESPONSE' + | 'AGENT_HUMAN_DECISION_CLAIM_UNCONFIRMED' + | 'AGENT_CLAIM_WITHOUT_EVIDENCE' + | 'AGENT_WORK_OUTSIDE_REQUEST'; + +export type DiagnosticSeverity = 'info' | 'warning' | 'review_required' | 'blocking'; + +export interface Diagnostic { + id: string; + code: DiagnosticCode; + severity: DiagnosticSeverity; + title: string; + detail: string; + recordIds: string[]; + suggestedAction: string; +} + +export interface DiagnosticReport { + schemaVersion: 't2c.diagnostics/v1'; + generatedAt: string; + graphFingerprint: string; + diagnostics: Diagnostic[]; + counts: Record; +} + +export type ConclusionKind = 'finding' | 'risk' | 'decision' | 'recommendation'; +export type TodoPriority = 'P0' | 'P1' | 'P2' | 'P3'; + +export interface GroundedGenerationMetadata { + generator: string; + generatorVersion: string; + runtimeVersion: string; + generatedAt: string; + requestedMode: 'deterministic' | 'prefer-llm' | 'require-llm'; + effectiveMode: 'deterministic' | 'llm'; + degraded: boolean; + model: string | null; + provider: string | null; + responseId: string | null; + configurationFingerprint: string; + reason: string | null; +} + +export interface Conclusion { + schemaVersion: 't2c.conclusion/v1'; + id: string; + kind: ConclusionKind; + title: string; + detail: string; + severity: DiagnosticSeverity; + diagnosticIds: string[]; + recordIds: string[]; + confidence: number; + generation: GroundedGenerationMetadata; +} + +export interface TodoProposal { + schemaVersion: 't2c.todo-proposal/v1'; + id: string; + title: string; + description: string; + priority: TodoPriority; + status: 'proposed'; + target: IntentTarget; + acceptanceCriteria: string[]; + dependencies: string[]; + conclusionIds: string[]; + diagnosticIds: string[]; + recordIds: string[]; + confidence: number; + generation: GroundedGenerationMetadata; +} diff --git a/src/core/types-runtime.ts b/src/core/types-runtime.ts new file mode 100644 index 00000000..a8aa4fab --- /dev/null +++ b/src/core/types-runtime.ts @@ -0,0 +1,220 @@ +import type { + IntentRecord, + JsonValue, +} from './types-foundation.js'; + +export interface TodoPatchDuplicateClassification { + proposalId: string; + existingRecordIds: string[]; + basis: string[]; +} + +export interface TodoPatchArtifact { + schemaVersion: 't2c.todo-patch/v1'; + createdAt: string; + sourceTodo: { + path: string; + contentHash: string; + }; + graphFingerprint: string; + diagnosticsFingerprint: string; + selectedProposalIds: string[]; + duplicateProposalIds: string[]; + duplicates: TodoPatchDuplicateClassification[]; + synthesisAudit: PipelineStageAudit; + renderedPatchHash: string; +} + +export interface TodoPatchApproval { + actor: string; + patchHash: string; +} + +export interface TodoApplyReceipt { + schemaVersion: 't2c.todo-apply-receipt/v1'; + patchHash: string; + sourceTodoHash: string; + resultTodoHash: string; + selectedProposalIds: string[]; + approvedBy: string; + approvedAt: string; + appliedAt: string; +} + +export interface TodoApplyResult { + applied: boolean; + idempotent: boolean; + receipt: TodoApplyReceipt; +} + +export interface ExtractionResult { + records: IntentRecord[]; + warnings: string[]; +} + +export interface ContentCacheStats { + hits: number; + misses: number; + writes: number; + recoveries: number; + errors: number; + bypassed: number; +} + +export interface CachedExtractionResult extends ExtractionResult { + cache: ContentCacheStats; +} + +export type LlmExtractionMode = 'deterministic' | 'prefer-llm' | 'require-llm'; +export type NlExtractionMode = LlmExtractionMode; + +export type PipelineStageStatus = 'succeeded' | 'partial' | 'fallback' | 'failed' | 'skipped'; +export type PipelineFailureStage = + | 'setup' + | 'naturalLanguageExtraction' + | 'gitExtraction' + | 'astExtraction' + | 'markdownExtraction' + | 'documentationExtraction' + | 'configurationExtraction' + | 'runtimeExtraction' + | 'communicationAnalysis' + | 'linking' + | 'diagnostics' + | 'taskSynthesis' + | 'todoRendering' + | 'codeChangePlanning' + | 'summary' + | 'persistence'; + +export interface LlmResponseMetadata extends Record { + responseId: string | null; + model: string | null; + provider: string | null; + usage: { + promptTokens: number | null; + completionTokens: number | null; + totalTokens: number | null; + cost: number | null; + } | null; +} + +export interface PipelineStageAudit { + runtimeVersion: string; + configuration: Record; + status: PipelineStageStatus; + requestedMode: 'deterministic' | 'llm' | 'disabled'; + effectiveMode: 'deterministic' | 'llm' | 'none'; + degraded: boolean; + recordCount: number; + warningCount: number; + model: string | null; + durationMs: number; + reason: { code: string; message: string } | null; + responses: LlmResponseMetadata[]; +} + +export interface PipelineOptions { + root: string; + taskFile: string | null; + todoFile: string | null; + changelogFile: string | null; + documentPatterns: string[]; + includeDocumentationLlm: boolean; + outputDir: string; + gitCommitCount: number; + allowSummaryFallback: boolean; + includeSummaryLlm?: boolean; + nlMode?: NlExtractionMode; + markdownMode?: LlmExtractionMode; + communicationMode?: LlmExtractionMode; + documentExcludes?: string[]; + taskSynthesisMode?: 'disabled' | 'prefer-llm' | 'require-llm'; + includeCommunication?: boolean; + projectDirectory?: string; + communicationTicket?: string | null; + /** autonom cycle document; runtime evidence is skipped when absent. */ + cycleFile?: string | null; +} + +export interface PipelineManifest { + schemaVersion: 't2c.run/v1'; + runId: string; + root: string; + createdAt: string; + graphFingerprint: string | null; + files: Record; + warnings: string[]; + status: 'succeeded' | 'degraded' | 'failed'; + failure: { stage: PipelineFailureStage; code: string; message: string } | null; + runtime: { + name: 'todo2code'; + version: string; + }; + configuration: { + fingerprint: string; + nlMode: NlExtractionMode; + markdownMode: LlmExtractionMode; + communicationMode: LlmExtractionMode; + gitCommitCount: number; + maxFileBytes: number; + markdownConcurrency: number; + documentConcurrency: number; + documentChunkChars: number; + documentMaxChunks: number; + documentRecordsPerChunk: number; + documentTimeoutMs: number; + summaryLlm: boolean; + taskSynthesisMode: 'disabled' | 'prefer-llm' | 'require-llm'; + includeCommunication: boolean; + projectDirectory: string; + communicationTicket: string | null; + documentPatterns: string[]; + documentExcludes: string[]; + adapters: { + python: { enabled: boolean; executable: string }; + go: { enabled: boolean; executable: string }; + java: { enabled: boolean; executable: string }; + rust: { enabled: boolean; executable: string }; + php: { enabled: boolean; executable: string }; + tensorflow: { + enabled: boolean; + modelPath: string | null; + modulePath: string; + labels: string[]; + }; + }; + llm: { + configured: boolean; + baseUrl: string; + nlModel: string; + markdownModel: string; + communicationModel: string; + documentModel: string; + summaryModel: string; + taskModel: string; + timeoutMs: number; + maxTokens: number; + temperature: number; + requireStructuredOutput: boolean; + responseHealing: boolean; + }; + }; + stages: { + naturalLanguageExtraction: PipelineStageAudit; + markdownExtraction: PipelineStageAudit; + documentationExtraction: PipelineStageAudit; + communicationAnalysis: PipelineStageAudit; + taskSynthesis: PipelineStageAudit; + codeChangePlanning: PipelineStageAudit; + summary: PipelineStageAudit; + }; + llm: { + naturalLanguageExtraction: boolean; + markdownExtraction: boolean; + communicationEnrichment: boolean; + documentationExtraction: boolean; + taskSynthesis: boolean; + summary: boolean; + }; +} diff --git a/src/core/types.ts b/src/core/types.ts index 16aa0c3d..c7701056 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,676 +1,3 @@ -export type JsonPrimitive = string | number | boolean | null; -export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; - -export type SourceKind = - | 'nl' - | 'git' - | 'ast' - | 'todo' - | 'changelog' - | 'document' - | 'agent_log' - | 'test' - | 'system'; - -export type EpistemicClass = - | 'declaration' - | 'plan' - | 'claim' - | 'fact' - | 'inference' - | 'llm_inference'; - -export type LifecycleStatus = - | 'proposed' - | 'planned' - | 'in_progress' - | 'implemented' - | 'verified' - | 'released' - | 'completed' - | 'blocked' - | 'unknown'; - -export type IntentAction = - | 'add' - | 'fix' - | 'remove' - | 'refactor' - | 'test' - | 'document' - | 'configure' - | 'analyze' - | 'validate' - | 'call' - | 'depend_on' - | 'declare' - | 'release' - | 'change' - | 'preserve' - | 'block' - | 'approve' - | 'unknown'; - -export type Modality = 'required' | 'recommended' | 'optional' | 'observed' | 'claimed' | 'unknown'; -export type Polarity = 'positive' | 'negative'; - -export interface SourceLineRange { - start: number; - end: number; -} - -export interface IntentTarget { - paths: string[]; - symbols: string[]; - tickets: string[]; - versions: string[]; -} - -export interface IntentStatement { - kind: string; - actor: string | null; - action: IntentAction; - subject: string | null; - object: string; - target: IntentTarget; - modality: Modality; - polarity: Polarity; - text: string; -} - -export interface IntentSource { - kind: SourceKind; - path: string | null; - lines: SourceLineRange | null; - revision: string | null; - symbol: string | null; - commitIndex: number | null; - extractor: string; - contentHash: string; - rawExcerpt: string | null; -} - -export interface IntentEpistemic { - class: EpistemicClass; - confidence: number; - basis: string[]; -} - -export interface IntentLifecycle { - status: LifecycleStatus; -} - -export type IntentGenerationMode = 'deterministic' | 'llm'; - -/** - * Runtime-owned provenance of the conversion that materialized one DSL record. - * This is required even for deterministic records: `source` identifies the - * evidence, while `generation` identifies the software or model that converted - * that evidence to Intent DSL. - */ -export interface IntentGenerationMetadata extends Record { - generator: string; - generatorVersion: string; - runtimeVersion: string; - requested: IntentGenerationMode; - used: IntentGenerationMode; - degraded: boolean; - fallbackReason: string | null; - provider: string | null; - model: string | null; - responseId: string | null; -} - -export interface IntentRecordMetadata extends Record { - generation: IntentGenerationMetadata; -} - -export interface IntentRecord { - schemaVersion: 't2c.intent/v1'; - id: string; - statement: IntentStatement; - lifecycle: IntentLifecycle; - source: IntentSource; - epistemic: IntentEpistemic; - observedAt: string | null; - metadata: IntentRecordMetadata; -} - -export type RelationType = - | 'declares' - | 'plans' - | 'implements' - | 'modifies' - | 'tests' - | 'documents' - | 'releases' - | 'depends_on' - | 'blocks' - | 'supersedes' - | 'contradicts' - | 'duplicates' - | 'evidenced_by' - | 'claimed_by' - | 'same_as' - | 'related_to'; - -export interface IntentRelation { - id: string; - from: string; - to: string; - type: RelationType; - confidence: number; - basis: string[]; -} - -export interface IntentGraph { - schemaVersion: 't2c.graph/v1'; - generatedAt: string; - fingerprint: string; - records: IntentRecord[]; - relations: IntentRelation[]; - stats: { - bySource: Record; - byAction: Record; - byStatus: Record; - }; -} - -export interface IntentRecordChange { - identity: string; - before: IntentRecord; - after: IntentRecord; - changedFields: string[]; -} - -export interface IntentGraphDiff { - schemaVersion: 't2c.diff/v1'; - generatedAt: string; - fingerprint: string; - beforeFingerprint: string; - afterFingerprint: string; - records: { - added: IntentRecord[]; - removed: IntentRecord[]; - changed: IntentRecordChange[]; - unchanged: number; - }; - relations: { - added: IntentRelation[]; - removed: IntentRelation[]; - unchanged: number; - }; - summary: { - recordsAdded: number; - recordsRemoved: number; - recordsChanged: number; - recordsUnchanged: number; - relationsAdded: number; - relationsRemoved: number; - relationsUnchanged: number; - }; -} - -export type DiagnosticCode = - | 'ALIGNED' - | 'PLANNED_NOT_IMPLEMENTED' - | 'IMPLEMENTED_NOT_PLANNED' - | 'IMPLEMENTED_NOT_DOCUMENTED' - | 'CHANGELOG_WITHOUT_IMPLEMENTATION' - | 'CONFLICTING_INTENT' - | 'AMBIGUOUS_REQUIREMENT' - | 'UNLINKED_RECORD' - | 'LOW_CONFIDENCE' - | 'INSUFFICIENT_EVIDENCE' - | 'LLM_NOT_CONFIGURED' - | 'SOURCE_UNAVAILABLE' - | 'PARTICIPANT_IDENTITY_UNRESOLVED' - | 'HUMAN_COMMUNICATION_CONFLICT' - | 'AGENT_COMMUNICATION_CONFLICT' - | 'HUMAN_AGENT_CONFLICT' - | 'REQUEST_WITHOUT_AGENT_RESPONSE' - | 'AGENT_HUMAN_DECISION_CLAIM_UNCONFIRMED' - | 'AGENT_CLAIM_WITHOUT_EVIDENCE' - | 'AGENT_WORK_OUTSIDE_REQUEST'; - -export type DiagnosticSeverity = 'info' | 'warning' | 'review_required' | 'blocking'; - -export interface Diagnostic { - id: string; - code: DiagnosticCode; - severity: DiagnosticSeverity; - title: string; - detail: string; - recordIds: string[]; - suggestedAction: string; -} - -export interface DiagnosticReport { - schemaVersion: 't2c.diagnostics/v1'; - generatedAt: string; - graphFingerprint: string; - diagnostics: Diagnostic[]; - counts: Record; -} - -export type ConclusionKind = 'finding' | 'risk' | 'decision' | 'recommendation'; -export type TodoPriority = 'P0' | 'P1' | 'P2' | 'P3'; - -export interface GroundedGenerationMetadata { - generator: string; - generatorVersion: string; - runtimeVersion: string; - generatedAt: string; - requestedMode: 'deterministic' | 'prefer-llm' | 'require-llm'; - effectiveMode: 'deterministic' | 'llm'; - degraded: boolean; - model: string | null; - provider: string | null; - responseId: string | null; - configurationFingerprint: string; - reason: string | null; -} - -export interface Conclusion { - schemaVersion: 't2c.conclusion/v1'; - id: string; - kind: ConclusionKind; - title: string; - detail: string; - severity: DiagnosticSeverity; - diagnosticIds: string[]; - recordIds: string[]; - confidence: number; - generation: GroundedGenerationMetadata; -} - -export interface TodoProposal { - schemaVersion: 't2c.todo-proposal/v1'; - id: string; - title: string; - description: string; - priority: TodoPriority; - status: 'proposed'; - target: IntentTarget; - acceptanceCriteria: string[]; - dependencies: string[]; - conclusionIds: string[]; - diagnosticIds: string[]; - recordIds: string[]; - confidence: number; - generation: GroundedGenerationMetadata; -} - -/** - * Grounded proposal to change source files so that a plan/diagnostic can be - * closed by later re-analysis. Status is always `proposed`: the runtime never - * applies a code change and never marks work DONE from this contract alone. - */ -export type CodeChangeFileAction = 'create' | 'modify' | 'delete'; - -export interface CodeChangeFile { - path: string; - action: CodeChangeFileAction; - symbols: string[]; - rationale: string; -} - -export type CodeChangeRiskLevel = 'low' | 'medium' | 'high'; - -export interface CodeChangeRisk { - level: CodeChangeRiskLevel; - reasons: string[]; -} - -export interface CodeChangePlan { - schemaVersion: 't2c.code-change-plan/v1'; - id: string; - planHash: string; - status: 'proposed'; - createdAt: string; - title: string; - description: string; - priority: TodoPriority; - target: IntentTarget; - acceptanceCriteria: string[]; - changes: CodeChangeFile[]; - risk: CodeChangeRisk; - rollback: string; - evidence: { - graphFingerprint: string; - recordIds: string[]; - diagnosticIds: string[]; - conclusionIds: string[]; - proposalIds: string[]; - }; - confidence: number; - generation: GroundedGenerationMetadata; -} - -/** - * Result of re-diagnosing a graph after an attempted implementation. - * Acceptance requires every cited diagnostic to clear and no new blocking - * diagnostics to appear. Remaining non-targeted warnings do not fail the gate. - */ -export interface CodeChangeAcceptance { - schemaVersion: 't2c.code-change-acceptance/v1'; - planId: string; - planHash: string; - beforeGraphFingerprint: string; - afterGraphFingerprint: string; - beforeDiagnosticIds: string[]; - afterDiagnosticIds: string[]; - clearedDiagnosticIds: string[]; - remainingDiagnosticIds: string[]; - newBlockingDiagnosticIds: string[]; - accepted: boolean; - reasons: string[]; - evaluatedAt: string; - generation: GroundedGenerationMetadata; -} - -/** Aggregate, non-authoritative result for closing one or more code-change plans. */ -export interface CodeChangeCloseResult { - schemaVersion: 't2c.code-change-close-result/v1'; - evaluatedAt: string; - graphFingerprintBefore: string; - graphFingerprintAfter: string; - planCount: number; - acceptedCount: number; - rejectedCount: number; - allAccepted: boolean; - acceptances: CodeChangeAcceptance[]; - generation: GroundedGenerationMetadata; -} - -/** - * Reviewable, non-executable projection of code-change plans. - * Carries a content hash of the Markdown so reviewers can detect tampering. - * The runtime never applies this artifact to source files. - */ -export interface CodeChangeReviewPatch { - schemaVersion: 't2c.code-change-review/v1'; - createdAt: string; - graphFingerprint: string; - planIds: string[]; - planHashes: string[]; - renderedPatchHash: string; - generation: GroundedGenerationMetadata; -} - -/** - * Structured source-edit proposal bound to one code-change plan. - * Instructions (and optional unified diffs) may only name paths declared by the - * plan. Application requires an explicit approval hash and never runs by default. - */ -export interface CodeChangeSourceEdit { - path: string; - action: CodeChangeFileAction; - symbols: string[]; - instruction: string; - /** - * Optional unified diff body for the single file. Null for instruction-only - * deterministic proposals. When set, the runtime checks path headers and - * rejects parent traversal / absolute host paths. Apply requires a non-null - * diff for every edit. - */ - unifiedDiff: string | null; -} - -export interface CodeChangeSourcePatch { - schemaVersion: 't2c.code-change-source-patch/v1'; - id: string; - patchHash: string; - status: 'proposed'; - createdAt: string; - planId: string; - planHash: string; - graphFingerprint: string; - diagnosticIds: string[]; - recordIds: string[]; - edits: CodeChangeSourceEdit[]; - acceptanceCriteria: string[]; - generation: GroundedGenerationMetadata; -} - -export interface CodeChangeSourcePatchSet { - schemaVersion: 't2c.code-change-source-patch-set/v1'; - generatedAt: string; - graphFingerprint: string; - patches: CodeChangeSourcePatch[]; - generation: GroundedGenerationMetadata; -} - -export interface CodeChangeSourcePatchApproval { - actor: string; - patchHash: string; -} - -export interface CodeChangeSourceApplyReceipt { - schemaVersion: 't2c.code-change-source-apply-receipt/v1'; - patchId: string; - patchHash: string; - planId: string; - approvedBy: string; - approvedAt: string; - appliedAt: string; - appliedPaths: string[]; - fileHashesAfter: Record; - generation: GroundedGenerationMetadata; -} - -export interface TodoPatchDuplicateClassification { - proposalId: string; - existingRecordIds: string[]; - basis: string[]; -} - -export interface TodoPatchArtifact { - schemaVersion: 't2c.todo-patch/v1'; - createdAt: string; - sourceTodo: { - path: string; - contentHash: string; - }; - graphFingerprint: string; - diagnosticsFingerprint: string; - selectedProposalIds: string[]; - duplicateProposalIds: string[]; - duplicates: TodoPatchDuplicateClassification[]; - synthesisAudit: PipelineStageAudit; - renderedPatchHash: string; -} - -export interface TodoPatchApproval { - actor: string; - patchHash: string; -} - -export interface TodoApplyReceipt { - schemaVersion: 't2c.todo-apply-receipt/v1'; - patchHash: string; - sourceTodoHash: string; - resultTodoHash: string; - selectedProposalIds: string[]; - approvedBy: string; - approvedAt: string; - appliedAt: string; -} - -export interface TodoApplyResult { - applied: boolean; - idempotent: boolean; - receipt: TodoApplyReceipt; -} - -export interface ExtractionResult { - records: IntentRecord[]; - warnings: string[]; -} - -export interface ContentCacheStats { - hits: number; - misses: number; - writes: number; - recoveries: number; - errors: number; - bypassed: number; -} - -export interface CachedExtractionResult extends ExtractionResult { - cache: ContentCacheStats; -} - -export type LlmExtractionMode = 'deterministic' | 'prefer-llm' | 'require-llm'; -export type NlExtractionMode = LlmExtractionMode; - -export type PipelineStageStatus = 'succeeded' | 'partial' | 'fallback' | 'failed' | 'skipped'; -export type PipelineFailureStage = - | 'setup' - | 'naturalLanguageExtraction' - | 'gitExtraction' - | 'astExtraction' - | 'markdownExtraction' - | 'documentationExtraction' - | 'configurationExtraction' - | 'runtimeExtraction' - | 'communicationAnalysis' - | 'linking' - | 'diagnostics' - | 'taskSynthesis' - | 'todoRendering' - | 'codeChangePlanning' - | 'summary' - | 'persistence'; - -export interface LlmResponseMetadata extends Record { - responseId: string | null; - model: string | null; - provider: string | null; - usage: { - promptTokens: number | null; - completionTokens: number | null; - totalTokens: number | null; - cost: number | null; - } | null; -} - -export interface PipelineStageAudit { - runtimeVersion: string; - configuration: Record; - status: PipelineStageStatus; - requestedMode: 'deterministic' | 'llm' | 'disabled'; - effectiveMode: 'deterministic' | 'llm' | 'none'; - degraded: boolean; - recordCount: number; - warningCount: number; - model: string | null; - durationMs: number; - reason: { code: string; message: string } | null; - responses: LlmResponseMetadata[]; -} - -export interface PipelineOptions { - root: string; - taskFile: string | null; - todoFile: string | null; - changelogFile: string | null; - documentPatterns: string[]; - includeDocumentationLlm: boolean; - outputDir: string; - gitCommitCount: number; - allowSummaryFallback: boolean; - includeSummaryLlm?: boolean; - nlMode?: NlExtractionMode; - markdownMode?: LlmExtractionMode; - communicationMode?: LlmExtractionMode; - documentExcludes?: string[]; - taskSynthesisMode?: 'disabled' | 'prefer-llm' | 'require-llm'; - includeCommunication?: boolean; - projectDirectory?: string; - communicationTicket?: string | null; - /** autonom cycle document; runtime evidence is skipped when absent. */ - cycleFile?: string | null; -} - -export interface PipelineManifest { - schemaVersion: 't2c.run/v1'; - runId: string; - root: string; - createdAt: string; - graphFingerprint: string | null; - files: Record; - warnings: string[]; - status: 'succeeded' | 'degraded' | 'failed'; - failure: { stage: PipelineFailureStage; code: string; message: string } | null; - runtime: { - name: 'todo2code'; - version: string; - }; - configuration: { - fingerprint: string; - nlMode: NlExtractionMode; - markdownMode: LlmExtractionMode; - communicationMode: LlmExtractionMode; - gitCommitCount: number; - maxFileBytes: number; - markdownConcurrency: number; - documentConcurrency: number; - documentChunkChars: number; - documentMaxChunks: number; - documentRecordsPerChunk: number; - documentTimeoutMs: number; - summaryLlm: boolean; - taskSynthesisMode: 'disabled' | 'prefer-llm' | 'require-llm'; - includeCommunication: boolean; - projectDirectory: string; - communicationTicket: string | null; - documentPatterns: string[]; - documentExcludes: string[]; - adapters: { - python: { enabled: boolean; executable: string }; - go: { enabled: boolean; executable: string }; - java: { enabled: boolean; executable: string }; - rust: { enabled: boolean; executable: string }; - php: { enabled: boolean; executable: string }; - tensorflow: { - enabled: boolean; - modelPath: string | null; - modulePath: string; - labels: string[]; - }; - }; - llm: { - configured: boolean; - baseUrl: string; - nlModel: string; - markdownModel: string; - communicationModel: string; - documentModel: string; - summaryModel: string; - taskModel: string; - timeoutMs: number; - maxTokens: number; - temperature: number; - requireStructuredOutput: boolean; - responseHealing: boolean; - }; - }; - stages: { - naturalLanguageExtraction: PipelineStageAudit; - markdownExtraction: PipelineStageAudit; - documentationExtraction: PipelineStageAudit; - communicationAnalysis: PipelineStageAudit; - taskSynthesis: PipelineStageAudit; - codeChangePlanning: PipelineStageAudit; - summary: PipelineStageAudit; - }; - llm: { - naturalLanguageExtraction: boolean; - markdownExtraction: boolean; - communicationEnrichment: boolean; - documentationExtraction: boolean; - taskSynthesis: boolean; - summary: boolean; - }; -} +export * from './types-foundation.js'; +export * from './types-code-change.js'; +export * from './types-runtime.js'; diff --git a/src/extractors/ast/typescript.ts b/src/extractors/ast/typescript.ts index 8991dc8d..8f438024 100644 --- a/src/extractors/ast/typescript.ts +++ b/src/extractors/ast/typescript.ts @@ -74,39 +74,51 @@ export function extractTypeScriptFile(root: string, filePath: string, body: stri return (ts.getModifiers(node) ?? []).map((modifier) => ts.SyntaxKind[modifier.kind] ?? String(modifier.kind)); } - function visit(node: ts.Node): void { + function addDependency(node: ts.Node): boolean { if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { add(node, { kind: 'module_dependency_fact', action: 'depend_on', object: node.moduleSpecifier.text, metadata: { importClause: node.importClause?.getText(sourceFile) ?? null } }); - } else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { + return true; + } + if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { add(node, { kind: 'module_dependency_fact', action: 'depend_on', object: node.moduleSpecifier.text, metadata: { reExport: true } }); - } else if ( - ts.isFunctionDeclaration(node) + return true; + } + return false; + } + + function isNamedDeclaration(node: ts.Node): node is ts.Declaration & { name?: ts.Node } { + return ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isEnumDeclaration(node) - || ts.isMethodDeclaration(node) - ) { - const symbol = nameOf(node); - if (symbol) { - const symbolModifiers = modifiers(node); - add(node, { - kind: 'symbol_fact', - action: 'declare', - object: symbol, - symbol, - metadata: { - symbolKind: ts.SyntaxKind[node.kind] ?? 'unknown', - modifiers: symbolModifiers, - exported: symbolModifiers.includes('ExportKeyword'), - }, - }); - scope.push(symbol); - ts.forEachChild(node, visit); - scope.pop(); - return; - } - } else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { + || ts.isMethodDeclaration(node); + } + + function visitNamedDeclaration(node: ts.Node): boolean { + if (!isNamedDeclaration(node)) return false; + const symbol = nameOf(node); + if (!symbol) return false; + const symbolModifiers = modifiers(node); + add(node, { + kind: 'symbol_fact', + action: 'declare', + object: symbol, + symbol, + metadata: { + symbolKind: ts.SyntaxKind[node.kind] ?? 'unknown', + modifiers: symbolModifiers, + exported: symbolModifiers.includes('ExportKeyword'), + }, + }); + scope.push(symbol); + ts.forEachChild(node, visit); + scope.pop(); + return true; + } + + function addVariable(node: ts.Node): void { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { const declarationIsCallable = Boolean(node.initializer && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))); if (declarationIsCallable || isTopLevel(node)) { add(node, { @@ -117,16 +129,26 @@ export function extractTypeScriptFile(root: string, filePath: string, body: stri metadata: { symbolKind: declarationIsCallable ? 'callable_variable' : 'variable' }, }); } - } else if (ts.isCallExpression(node)) { - const callee = node.expression.getText(sourceFile).slice(0, 300); - add(node, { - kind: 'call_fact', - action: 'call', - object: callee, - symbol: scope.length ? scope.join('.') : null, - metadata: { callee, argumentCount: node.arguments.length }, - }); } + } + + function addCall(node: ts.Node): void { + if (!ts.isCallExpression(node)) return; + const callee = node.expression.getText(sourceFile).slice(0, 300); + add(node, { + kind: 'call_fact', + action: 'call', + object: callee, + symbol: scope.length ? scope.join('.') : null, + metadata: { callee, argumentCount: node.arguments.length }, + }); + } + + function visit(node: ts.Node): void { + addDependency(node); + if (visitNamedDeclaration(node)) return; + addVariable(node); + addCall(node); ts.forEachChild(node, visit); } diff --git a/src/extractors/communication-attribution.ts b/src/extractors/communication-attribution.ts new file mode 100644 index 00000000..14f77fb0 --- /dev/null +++ b/src/extractors/communication-attribution.ts @@ -0,0 +1,188 @@ +import path from 'node:path'; + +import type { EpistemicClass, LifecycleStatus } from '../core/types.js'; +import type { + LoadedParticipantIdentityRegistry, + ParticipantIdentityEntry, +} from '../communication/identity.js'; +import type { + CommunicationAttribution, + CommunicationCandidate, + CommunicationRole, + CommunicationType, +} from './communication-types.js'; +import { + first, + listValue, + normalizeRole, + normalizeType, + validTimestamp, +} from './communication-envelope.js'; + +export function resolveAttribution( + candidate: CommunicationCandidate, + identityRegistry: LoadedParticipantIdentityRegistry | null, +): CommunicationAttribution { + return { + ...resolveParticipantAttribution(candidate, identityRegistry), + ...resolveMessageAttribution(candidate), + ...resolveTargetAttribution(candidate.envelope.metadata), + }; +} + +export function attributionWarnings( + candidate: CommunicationCandidate, + attribution: CommunicationAttribution, + identityRegistry: LoadedParticipantIdentityRegistry | null, +): string[] { + return [ + ...basicAttributionWarnings(candidate.relativeToProject, attribution, identityRegistry), + ...registryAttributionWarnings(candidate, attribution, identityRegistry), + ...timestampAttributionWarnings(candidate.relativeToProject, attribution), + ]; +} + +export function semanticsFor( + type: CommunicationType, + role: CommunicationRole, +): { lifecycle: LifecycleStatus; epistemicClass: EpistemicClass } { + if (type === 'plan') return { lifecycle: 'planned', epistemicClass: 'plan' }; + if (type === 'report' || type === 'result' || type === 'claim') return { lifecycle: 'implemented', epistemicClass: 'claim' }; + if (type === 'decision') return { lifecycle: 'completed', epistemicClass: 'declaration' }; + return { lifecycle: 'proposed', epistemicClass: role === 'agent' ? 'claim' : 'declaration' }; +} + +export function resolveIdentity( + byId: Map | null, + participantId: string | null, +): { entry: ParticipantIdentityEntry | null } { + if (!byId || !participantId) return { entry: null }; + return { entry: byId.get(participantId) ?? null }; +} + +function resolveParticipantAttribution( + candidate: CommunicationCandidate, + identityRegistry: LoadedParticipantIdentityRegistry | null, +): Pick { + const { envelope, inferred, file } = candidate; + const declaredParticipant = first(envelope.metadata.participant, envelope.metadata.actor, inferred.participant); + const declaredRole = normalizeRole(first(envelope.metadata.role, inferred.role)); + const declaredParticipantId = first(envelope.metadata.participant_id, envelope.metadata['participant-id']); + const entry = resolveIdentity(identityRegistry?.byId ?? null, declaredParticipantId).entry; + const participant = resolvedParticipant(entry, declaredParticipantId, declaredParticipant, file); + const declaredGitAuthors = listValue(first( + envelope.metadata.git_authors, envelope.metadata['git-authors'], envelope.metadata.git_author, + )); + return { + declaredParticipantId, + entry, + participant, + role: entry?.role ?? declaredRole, + displayName: resolvedDisplayName(entry, declaredParticipant, participant), + declaredGitAuthors, + gitAuthors: entry ? [...entry.gitAuthors] : declaredGitAuthors, + declaredA2aAgentId: first(envelope.metadata.a2a_agent_id, envelope.metadata['a2a-agent-id']), + }; +} + +function resolvedParticipant( + entry: ParticipantIdentityEntry | null, + declaredParticipantId: string | null, + declaredParticipant: string | null, + file: string, +): string { + return entry?.id ?? declaredParticipantId ?? declaredParticipant ?? `unknown:${path.basename(file)}`; +} + +function resolvedDisplayName( + entry: ParticipantIdentityEntry | null, + declaredParticipant: string | null, + participant: string, +): string { + return entry?.displayName ?? declaredParticipant ?? participant; +} + +function resolveMessageAttribution( + candidate: CommunicationCandidate, +): Pick { + const { envelope, inferred, pathTicket } = candidate; + const explicitMessageType = first(envelope.metadata.type, envelope.metadata.kind); + const rawTimestamp = first(envelope.metadata.timestamp, envelope.metadata.created_at, envelope.metadata.createdat); + return { + explicitMessageType, + messageType: normalizeType(first(explicitMessageType, inferred.type)), + ticket: first(envelope.metadata.ticket, pathTicket) ?? pathTicket, + recipient: first(envelope.metadata.recipient, envelope.metadata.to), + rawTimestamp, + timestamp: validTimestamp(rawTimestamp), + }; +} + +function resolveTargetAttribution( + metadata: Record, +): Pick { + return { + explicitPaths: listValue(first( + metadata.paths, metadata.target_paths, metadata['target-paths'], + )), + explicitSymbols: listValue(first( + metadata.symbols, metadata.target_symbols, metadata['target-symbols'], + )), + }; +} + +function basicAttributionWarnings( + source: string, + attribution: CommunicationAttribution, + identityRegistry: LoadedParticipantIdentityRegistry | null, +): string[] { + const warnings: string[] = []; + if (attribution.role === 'unknown') warnings.push(`${source}: role must be human or agent`); + if (attribution.participant.startsWith('unknown:')) warnings.push(`${source}: participant is missing`); + if (identityRegistry && !attribution.declaredParticipantId) { + warnings.push(`${source}: participant-id is required when project/participants.json exists`); + } else if (identityRegistry && !attribution.entry) { + warnings.push(`${source}: participant-id is not present in project/participants.json`); + } + return warnings; +} + +function registryAttributionWarnings( + candidate: CommunicationCandidate, + attribution: CommunicationAttribution, + identityRegistry: LoadedParticipantIdentityRegistry | null, +): string[] { + const warnings: string[] = []; + const source = candidate.relativeToProject; + const declaredRole = normalizeRole(first(candidate.envelope.metadata.role, candidate.inferred.role)); + if (attribution.entry && declaredRole !== 'unknown' && declaredRole !== attribution.entry.role) { + warnings.push(`${source}: declared role conflicts with participant registry`); + } + if (attribution.entry && attribution.declaredGitAuthors.length + && !sameStrings(attribution.declaredGitAuthors, attribution.entry.gitAuthors)) { + warnings.push(`${source}: git-authors differ from participant registry and were ignored`); + } + if (attribution.declaredA2aAgentId + && (!attribution.entry || !attribution.entry.a2aAgentIds.includes(attribution.declaredA2aAgentId))) { + warnings.push(`${source}: a2a-agent-id is not assigned to participant-id in the registry`); + } + return warnings; +} + +function timestampAttributionWarnings( + source: string, + attribution: CommunicationAttribution, +): string[] { + return attribution.rawTimestamp && !attribution.timestamp ? [`${source}: invalid timestamp`] : []; +} + +function sameStrings(left: string[], right: string[]): boolean { + const normalize = (values: string[]): string[] => values.map((value) => value.trim().toLowerCase()).sort(); + const normalizedLeft = normalize(left); + const normalizedRight = normalize(right); + return normalizedLeft.length === normalizedRight.length + && normalizedLeft.every((value, index) => value === normalizedRight[index]); +} diff --git a/src/extractors/communication-envelope.ts b/src/extractors/communication-envelope.ts new file mode 100644 index 00000000..1e1216eb --- /dev/null +++ b/src/extractors/communication-envelope.ts @@ -0,0 +1,217 @@ +import path from 'node:path'; + +import { splitIntentLines } from '../core/text.js'; +import type { + CommunicationRole, + CommunicationSegment, + CommunicationType, + InferredCommunicationIdentity, +} from './communication-types.js'; + +export function parseEnvelope(value: string): import('./communication-types.js').CommunicationEnvelope { + const lines = value.split(/\r?\n/); + if (lines[0]?.trim() !== '---') { + return { body: value, bodyStartLine: 1, metadata: {}, malformed: false }; + } + const end = lines.slice(1).findIndex((line) => line.trim() === '---'); + if (end < 0) return { body: '', bodyStartLine: 1, metadata: {}, malformed: true }; + const metadata: Record = {}; + for (const line of lines.slice(1, end + 1)) { + const match = line.match(/^\s*([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*?)\s*$/); + if (!match?.[1]) continue; + metadata[match[1].toLowerCase()] = unquote(match[2] ?? ''); + } + return { + body: lines.slice(end + 2).join('\n'), + bodyStartLine: end + 3, + metadata, + malformed: false, + }; +} + +export function inferIdentity(relativePath: string): InferredCommunicationIdentity { + const parts = relativePath.split('/'); + const basename = path.basename(relativePath, path.extname(relativePath)); + const governance = basename.match(/^(user|human|ai|agent)-(.+)$/i); + if (governance?.[1] && governance[2] && !/-logs$/i.test(governance[2])) { + const role = /^(user|human)$/i.test(governance[1]) ? 'human' : 'agent'; + return { + role, + participant: governance[2].toLowerCase(), + type: role === 'human' ? 'request' : 'plan', + governanceParticipantFile: true, + }; + } + const fileParts = basename.split('.'); + const nestedRoleIndex = parts.findIndex((part) => /^(agents?|humans?|users?)$/i.test(part)); + const nestedRole = nestedRoleIndex >= 0 ? parts[nestedRoleIndex] ?? null : null; + const nestedParticipant = nestedRoleIndex >= 0 ? parts[nestedRoleIndex + 1] ?? null : null; + const filenameRole = /^(agent|human|user)$/i.test(fileParts[0] ?? '') ? fileParts[0] ?? null : null; + return { + role: filenameRole ?? nestedRole, + participant: filenameRole ? fileParts[1] ?? null : nestedParticipant, + type: filenameRole ? fileParts[2] ?? null : fileParts.find((part) => isCommunicationType(part)) ?? null, + governanceParticipantFile: false, + }; +} + +export function isTicketEvidenceFile(relativePath: string): boolean { + const basename = path.basename(relativePath).toLowerCase(); + return [ + 'readme.md', + 'preprompt.md', + 'changelog.md', + 'audit.md', + 'baseline.md', + 'logs.txt', + ].includes(basename) + || /(?:^|[._-])(?:task|todo)\.md$/.test(basename) + || /^iteration-\d+(?:-[a-z0-9-]+)?\.md$/.test(basename) + || /^(?:ai|agent)-.+-logs\.txt$/.test(basename); +} + +export function hasExplicitCommunicationEnvelope(metadata: Record): boolean { + return Boolean(first( + metadata.participant, + metadata.participant_id, + metadata['participant-id'], + metadata.role, + metadata.type, + metadata.ticket, + )); +} + +export function looksLikeTicket(value: string): boolean { + return /^[A-Za-z][A-Za-z0-9]*-\d+(?:[-_][A-Za-z0-9]+)*$/.test(value); +} + +export function normalizeRole(value: string | null): CommunicationRole { + if (/^agents?$/i.test(value ?? '')) return 'agent'; + if (/^(human|humans|user|users|person)$/i.test(value ?? '')) return 'human'; + return 'unknown'; +} + +export function normalizeType(value: string | null): CommunicationType { + const normalized = value?.toLowerCase(); + return isCommunicationType(normalized ?? '') ? normalized as CommunicationType : 'message'; +} + +export function isCommunicationType(value: string): boolean { + return ['request', 'plan', 'decision', 'message', 'report', 'result', 'claim'].includes(value.toLowerCase()); +} + +export function first(...values: Array): string | null { + return values.find((value) => typeof value === 'string' && Boolean(value.trim()))?.trim() ?? null; +} + +export function listValue(value: string | null): string[] { + if (!value) return []; + const jsonValues = jsonStringList(value); + if (jsonValues) return [...new Set(jsonValues.map((item) => item.trim()).filter(Boolean))].sort(); + const stripped = value.replace(/^\[|\]$/g, ''); + return [...new Set(stripped.split(',').map((item) => unquote(item.trim())).filter(Boolean))].sort(); +} + +export function validTimestamp(value: string | null): string | null { + if (!value) return null; + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) { + return null; + } + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) ? null : parsed.toISOString(); +} + +export function communicationSegments( + body: string, + defaultType: CommunicationType, + governanceRole: CommunicationRole | null, +): CommunicationSegment[] { + if (!governanceRole || governanceRole === 'unknown') { + return splitIntentLines(body) + .filter((segment) => !isCommunicationNoise(segment.text)) + .map((segment) => ({ ...segment, type: defaultType })); + } + const output: CommunicationSegment[] = []; + const lines = body.split(/\r?\n/); + let sectionType: CommunicationType | null = null; + let pending: { text: string; line: number; type: CommunicationType } | null = null; + const flush = (): void => { + if (!pending) return; + const item = pending; + pending = null; + if (/^(?:none|brak)[.!]?$/i.test(item.text.trim())) return; + for (const segment of splitIntentLines(item.text)) { + if (isCommunicationNoise(segment.text)) continue; + output.push({ text: segment.text, line: item.line + segment.line - 1, type: item.type }); + } + }; + for (let index = 0; index < lines.length; index += 1) { + const raw = lines[index] ?? ''; + const heading = raw.match(/^\s{0,3}#{2,6}\s+(.+?)\s*$/)?.[1]; + if (heading) { + flush(); + sectionType = governanceSectionType(heading, governanceRole); + continue; + } + if (!sectionType) { + flush(); + continue; + } + if (!raw.trim()) { + flush(); + continue; + } + const startsListItem = /^\s*(?:[-*+]|\d+[.)]|\[[ xX]\])\s+/.test(raw); + if (startsListItem) flush(); + const cleaned = raw + .replace(/^\s*[-*+]\s+/, '') + .replace(/^\s*\d+[.)]\s+/, '') + .replace(/^\s*\[[ xX]\]\s+/, '') + .trim(); + if (!cleaned) continue; + if (pending) pending.text = `${pending.text} ${cleaned}`; + else pending = { text: cleaned, line: index + 1, type: sectionType }; + } + flush(); + return output; +} + +function isCommunicationNoise(value: string): boolean { + const normalized = value.trim(); + return /^#{1,6}\s*\d+(?:[.):_-]\d+)*[.):_-]?\s*$/.test(normalized) + || /^(?:-{3,}|_{3,}|\*{3,})$/.test(normalized); +} + +function governanceSectionType(heading: string, role: Exclude): CommunicationType | null { + const normalized = heading.toLowerCase().replace(/[^a-z0-9ąćęłńóśźż]+/gi, ' ').trim(); + if (role === 'human') { + if (/\b(decision|decisions|decyzj|approval|zatwierdzen)/i.test(normalized)) return 'decision'; + if (/\b(instruction|instructions|request|requirements?|goal|scope|polecen|wymagan|zakres|cel)\b/i.test(normalized)) { + return 'request'; + } + return null; + } + if (/\b(actual changes?|result|results|report|unfinished|blockers?|wykonan|zmian|wynik|raport|blokad)\b/i.test(normalized)) { + return 'report'; + } + if (/\b(understanding|execution plan|plan|scope|guardrails?|risks?|hypotheses|code locations?|rozumien|zakres|ryzyk)\b/i.test(normalized)) { + return 'plan'; + } + if (/\b(approval|zatwierdzen)\b/i.test(normalized)) return 'claim'; + return null; +} + +function jsonStringList(value: string): string[] | null { + if (!value.startsWith('[') || !value.endsWith(']')) return null; + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) && parsed.every((item) => typeof item === 'string') + ? parsed : null; + } catch { + return null; + } +} + +function unquote(value: string): string { + return value.replace(/^['"]|['"]$/g, '').trim(); +} diff --git a/src/extractors/communication-types.ts b/src/extractors/communication-types.ts new file mode 100644 index 00000000..6632f06e --- /dev/null +++ b/src/extractors/communication-types.ts @@ -0,0 +1,57 @@ +import type { ParticipantIdentityEntry } from '../communication/identity.js'; + +export type CommunicationRole = 'human' | 'agent' | 'unknown'; +export type CommunicationType = 'request' | 'plan' | 'decision' | 'message' | 'report' | 'result' | 'claim'; + +export interface CommunicationExtractionOptions { + root: string; + projectDir?: string; + ticket?: string | null; +} + +export interface CommunicationEnvelope { + body: string; + bodyStartLine: number; + metadata: Record; + malformed: boolean; +} + +export interface InferredCommunicationIdentity { + role: string | null; + participant: string | null; + type: string | null; + governanceParticipantFile: boolean; +} + +export interface CommunicationSegment { + text: string; + line: number; + type: CommunicationType; +} + +export interface CommunicationCandidate { + file: string; + relativeToProject: string; + pathTicket: string; + envelope: CommunicationEnvelope; + inferred: InferredCommunicationIdentity; +} + +export interface CommunicationAttribution { + declaredParticipantId: string | null; + entry: ParticipantIdentityEntry | null; + participant: string; + role: CommunicationRole; + displayName: string; + explicitMessageType: string | null; + messageType: CommunicationType; + ticket: string; + recipient: string | null; + rawTimestamp: string | null; + timestamp: string | null; + declaredGitAuthors: string[]; + gitAuthors: string[]; + declaredA2aAgentId: string | null; + explicitPaths: string[]; + explicitSymbols: string[]; +} diff --git a/src/extractors/communication.ts b/src/extractors/communication.ts index d97d53ed..429280e2 100644 --- a/src/extractors/communication.ts +++ b/src/extractors/communication.ts @@ -1,4 +1,5 @@ import path from 'node:path'; + import type { T2CConfig } from '../config/env.js'; import { pathExists, readText, relativePosix, walkFiles } from '../core/io.js'; import { buildRecord } from '../core/record.js'; @@ -11,71 +12,40 @@ import { extractTickets, extractVersions, inferObject, - splitIntentLines, } from '../core/text.js'; -import type { EpistemicClass, ExtractionResult, JsonValue, LifecycleStatus } from '../core/types.js'; +import type { ExtractionResult, JsonValue } from '../core/types.js'; import { classifyAction } from '../tf/classifier.js'; import { loadParticipantIdentityRegistry, type LoadedParticipantIdentityRegistry, - type ParticipantIdentityEntry, } from '../communication/identity.js'; - -export type CommunicationRole = 'human' | 'agent' | 'unknown'; -export type CommunicationType = 'request' | 'plan' | 'decision' | 'message' | 'report' | 'result' | 'claim'; - -export interface CommunicationExtractionOptions { - root: string; - projectDir?: string; - ticket?: string | null; -} - -interface CommunicationEnvelope { - body: string; - bodyStartLine: number; - metadata: Record; - malformed: boolean; -} - -interface InferredCommunicationIdentity { - role: string | null; - participant: string | null; - type: string | null; - governanceParticipantFile: boolean; -} - -interface CommunicationSegment { - text: string; - line: number; - type: CommunicationType; -} - -interface CommunicationCandidate { - file: string; - relativeToProject: string; - pathTicket: string; - envelope: CommunicationEnvelope; - inferred: InferredCommunicationIdentity; -} - -interface CommunicationAttribution { - declaredParticipantId: string | null; - entry: ParticipantIdentityEntry | null; - participant: string; - role: CommunicationRole; - displayName: string; - explicitMessageType: string | null; - messageType: CommunicationType; - ticket: string; - recipient: string | null; - rawTimestamp: string | null; - timestamp: string | null; - declaredGitAuthors: string[]; - gitAuthors: string[]; - declaredA2aAgentId: string | null; - explicitPaths: string[]; - explicitSymbols: string[]; -} +import { + attributionWarnings, + resolveAttribution, + semanticsFor, +} from './communication-attribution.js'; +import { + communicationSegments, + hasExplicitCommunicationEnvelope, + inferIdentity, + isTicketEvidenceFile, + looksLikeTicket, + parseEnvelope, +} from './communication-envelope.js'; +import type { + CommunicationAttribution, + CommunicationCandidate, + CommunicationExtractionOptions, + CommunicationRole, + CommunicationSegment, + CommunicationType, +} from './communication-types.js'; + +export type { + CommunicationExtractionOptions, + CommunicationRole, + CommunicationType, +} from './communication-types.js'; /** * Converts append-only human/agent communication under project// to @@ -183,7 +153,7 @@ function shouldIgnoreCommunicationCandidate( pathTicket: string, requestedTicket: string | null, hasIdentityRegistry: boolean, - inferred: InferredCommunicationIdentity, + inferred: CommunicationCandidate['inferred'], explicitEnvelope: boolean, ): boolean { if (explicitEnvelope) return false; @@ -192,17 +162,6 @@ function shouldIgnoreCommunicationCandidate( return !inferred.role; } -function hasExplicitCommunicationEnvelope(metadata: Record): boolean { - return Boolean(first( - metadata.participant, - metadata.participant_id, - metadata['participant-id'], - metadata.role, - metadata.type, - metadata.ticket, - )); -} - async function convertCommunicationCandidate( root: string, candidate: CommunicationCandidate, @@ -230,148 +189,6 @@ async function convertCommunicationCandidate( return { records, warnings }; } -function resolveAttribution( - candidate: CommunicationCandidate, - identityRegistry: LoadedParticipantIdentityRegistry | null, -): CommunicationAttribution { - return { - ...resolveParticipantAttribution(candidate, identityRegistry), - ...resolveMessageAttribution(candidate), - ...resolveTargetAttribution(candidate.envelope.metadata), - }; -} - -function resolveParticipantAttribution( - candidate: CommunicationCandidate, - identityRegistry: LoadedParticipantIdentityRegistry | null, -): Pick { - const { envelope, inferred, file } = candidate; - const declaredParticipant = first(envelope.metadata.participant, envelope.metadata.actor, inferred.participant); - const declaredRole = normalizeRole(first(envelope.metadata.role, inferred.role)); - const declaredParticipantId = first(envelope.metadata.participant_id, envelope.metadata['participant-id']); - const entry = resolveIdentity(identityRegistry?.byId ?? null, declaredParticipantId).entry; - const participant = resolvedParticipant(entry, declaredParticipantId, declaredParticipant, file); - const declaredGitAuthors = listValue(first( - envelope.metadata.git_authors, envelope.metadata['git-authors'], envelope.metadata.git_author, - )); - return { - declaredParticipantId, - entry, - participant, - role: entry?.role ?? declaredRole, - displayName: resolvedDisplayName(entry, declaredParticipant, participant), - declaredGitAuthors, - gitAuthors: entry ? [...entry.gitAuthors] : declaredGitAuthors, - declaredA2aAgentId: first(envelope.metadata.a2a_agent_id, envelope.metadata['a2a-agent-id']), - }; -} - -function resolvedParticipant( - entry: ParticipantIdentityEntry | null, - declaredParticipantId: string | null, - declaredParticipant: string | null, - file: string, -): string { - return entry?.id ?? declaredParticipantId ?? declaredParticipant ?? `unknown:${path.basename(file)}`; -} - -function resolvedDisplayName( - entry: ParticipantIdentityEntry | null, - declaredParticipant: string | null, - participant: string, -): string { - return entry?.displayName ?? declaredParticipant ?? participant; -} - -function resolveMessageAttribution( - candidate: CommunicationCandidate, -): Pick { - const { envelope, inferred, pathTicket } = candidate; - const explicitMessageType = first(envelope.metadata.type, envelope.metadata.kind); - const rawTimestamp = first(envelope.metadata.timestamp, envelope.metadata.created_at, envelope.metadata.createdat); - return { - explicitMessageType, - messageType: normalizeType(first(explicitMessageType, inferred.type)), - ticket: first(envelope.metadata.ticket, pathTicket) ?? pathTicket, - recipient: first(envelope.metadata.recipient, envelope.metadata.to), - rawTimestamp, - timestamp: validTimestamp(rawTimestamp), - }; -} - -function resolveTargetAttribution( - metadata: Record, -): Pick { - return { - explicitPaths: listValue(first( - metadata.paths, metadata.target_paths, metadata['target-paths'], - )), - explicitSymbols: listValue(first( - metadata.symbols, metadata.target_symbols, metadata['target-symbols'], - )), - }; -} - -function attributionWarnings( - candidate: CommunicationCandidate, - attribution: CommunicationAttribution, - identityRegistry: LoadedParticipantIdentityRegistry | null, -): string[] { - return [ - ...basicAttributionWarnings(candidate.relativeToProject, attribution, identityRegistry), - ...registryAttributionWarnings(candidate, attribution, identityRegistry), - ...timestampAttributionWarnings(candidate.relativeToProject, attribution), - ]; -} - -function basicAttributionWarnings( - source: string, - attribution: CommunicationAttribution, - identityRegistry: LoadedParticipantIdentityRegistry | null, -): string[] { - const warnings: string[] = []; - if (attribution.role === 'unknown') warnings.push(`${source}: role must be human or agent`); - if (attribution.participant.startsWith('unknown:')) warnings.push(`${source}: participant is missing`); - if (identityRegistry && !attribution.declaredParticipantId) { - warnings.push(`${source}: participant-id is required when project/participants.json exists`); - } else if (identityRegistry && !attribution.entry) { - warnings.push(`${source}: participant-id is not present in project/participants.json`); - } - return warnings; -} - -function registryAttributionWarnings( - candidate: CommunicationCandidate, - attribution: CommunicationAttribution, - identityRegistry: LoadedParticipantIdentityRegistry | null, -): string[] { - const warnings: string[] = []; - const source = candidate.relativeToProject; - const declaredRole = normalizeRole(first(candidate.envelope.metadata.role, candidate.inferred.role)); - if (attribution.entry && declaredRole !== 'unknown' && declaredRole !== attribution.entry.role) { - warnings.push(`${source}: declared role conflicts with participant registry`); - } - if (attribution.entry && attribution.declaredGitAuthors.length - && !sameStrings(attribution.declaredGitAuthors, attribution.entry.gitAuthors)) { - warnings.push(`${source}: git-authors differ from participant registry and were ignored`); - } - if (attribution.declaredA2aAgentId - && (!attribution.entry || !attribution.entry.a2aAgentIds.includes(attribution.declaredA2aAgentId))) { - warnings.push(`${source}: a2a-agent-id is not assigned to participant-id in the registry`); - } - return warnings; -} - -function timestampAttributionWarnings( - source: string, - attribution: CommunicationAttribution, -): string[] { - return attribution.rawTimestamp && !attribution.timestamp ? [`${source}: invalid timestamp`] : []; -} - async function buildCommunicationIntentRecord( root: string, candidate: CommunicationCandidate, @@ -442,244 +259,3 @@ function communicationMetadata( llmUsed: false, }; } - -function resolveIdentity( - byId: Map | null, - participantId: string | null, -): { entry: ParticipantIdentityEntry | null } { - if (!byId || !participantId) return { entry: null }; - // Stable IDs are canonical and exact. Display names and aliases are never - // searched here, so a similar-looking name cannot acquire another identity. - return { entry: byId.get(participantId) ?? null }; -} - -function sameStrings(left: string[], right: string[]): boolean { - const normalize = (values: string[]): string[] => values.map((value) => value.trim().toLowerCase()).sort(); - const normalizedLeft = normalize(left); - const normalizedRight = normalize(right); - return normalizedLeft.length === normalizedRight.length - && normalizedLeft.every((value, index) => value === normalizedRight[index]); -} - -function parseEnvelope(value: string): CommunicationEnvelope { - const lines = value.split(/\r?\n/); - if (lines[0]?.trim() !== '---') { - return { body: value, bodyStartLine: 1, metadata: {}, malformed: false }; - } - const end = lines.slice(1).findIndex((line) => line.trim() === '---'); - if (end < 0) return { body: '', bodyStartLine: 1, metadata: {}, malformed: true }; - const metadata: Record = {}; - for (const line of lines.slice(1, end + 1)) { - const match = line.match(/^\s*([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*?)\s*$/); - if (!match?.[1]) continue; - metadata[match[1].toLowerCase()] = unquote(match[2] ?? ''); - } - return { - body: lines.slice(end + 2).join('\n'), - bodyStartLine: end + 3, - metadata, - malformed: false, - }; -} - -function inferIdentity(relativePath: string): InferredCommunicationIdentity { - const parts = relativePath.split('/'); - const basename = path.basename(relativePath, path.extname(relativePath)); - const governance = basename.match(/^(user|human|ai|agent)-(.+)$/i); - if (governance?.[1] && governance[2] && !/-logs$/i.test(governance[2])) { - const role = /^(user|human)$/i.test(governance[1]) ? 'human' : 'agent'; - return { - role, - participant: governance[2].toLowerCase(), - type: role === 'human' ? 'request' : 'plan', - governanceParticipantFile: true, - }; - } - const fileParts = basename.split('.'); - const nestedRoleIndex = parts.findIndex((part) => /^(agents?|humans?|users?)$/i.test(part)); - const nestedRole = nestedRoleIndex >= 0 ? parts[nestedRoleIndex] ?? null : null; - const nestedParticipant = nestedRoleIndex >= 0 ? parts[nestedRoleIndex + 1] ?? null : null; - const filenameRole = /^(agent|human|user)$/i.test(fileParts[0] ?? '') ? fileParts[0] ?? null : null; - return { - role: filenameRole ?? nestedRole, - participant: filenameRole ? fileParts[1] ?? null : nestedParticipant, - type: filenameRole ? fileParts[2] ?? null : fileParts.find((part) => isCommunicationType(part)) ?? null, - governanceParticipantFile: false, - }; -} - -/** - * Governance tickets contain specifications, raw logs and captured results - * beside participant files. Those artifacts are evidence for the ticket, not - * utterances by an anonymous participant. Explicit communication front matter - * still wins, so a deliberately authored file with one of these names remains - * available to callers. - */ -function isTicketEvidenceFile(relativePath: string): boolean { - const basename = path.basename(relativePath).toLowerCase(); - return [ - 'readme.md', - 'preprompt.md', - 'changelog.md', - 'audit.md', - 'baseline.md', - 'logs.txt', - ].includes(basename) - // Pipeline-selected task and TODO projections are already consumed by the - // NL/Markdown extractors. Treat conventional generated filenames as - // evidence so they are not ingested a second time as an anonymous person. - // Explicit communication front matter is checked before this classifier - // and remains the deliberate opt-in for an evidence-like filename. - || /(?:^|[._-])(?:task|todo)\.md$/.test(basename) - || /^iteration-\d+(?:-[a-z0-9-]+)?\.md$/.test(basename) - || /^(?:ai|agent)-.+-logs\.txt$/.test(basename); -} - -/** - * The governance-standard participant files are structured documents rather - * than one homogeneous message. Heading ownership is deterministic: - * instructions/decisions belong to the human, while an agent's plan and actual - * changes remain different epistemic classes. Metadata and ownership boilerplate - * are deliberately skipped. - */ -function communicationSegments( - body: string, - defaultType: CommunicationType, - governanceRole: CommunicationRole | null, -): CommunicationSegment[] { - if (!governanceRole || governanceRole === 'unknown') { - return splitIntentLines(body) - .filter((segment) => !isCommunicationNoise(segment.text)) - .map((segment) => ({ ...segment, type: defaultType })); - } - const output: CommunicationSegment[] = []; - const lines = body.split(/\r?\n/); - let sectionType: CommunicationType | null = null; - let pending: { text: string; line: number; type: CommunicationType } | null = null; - const flush = (): void => { - if (!pending) return; - const item = pending; - pending = null; - if (/^(?:none|brak)[.!]?$/i.test(item.text.trim())) return; - for (const segment of splitIntentLines(item.text)) { - if (isCommunicationNoise(segment.text)) continue; - output.push({ text: segment.text, line: item.line + segment.line - 1, type: item.type }); - } - }; - for (let index = 0; index < lines.length; index += 1) { - const raw = lines[index] ?? ''; - const heading = raw.match(/^\s{0,3}#{2,6}\s+(.+?)\s*$/)?.[1]; - if (heading) { - flush(); - sectionType = governanceSectionType(heading, governanceRole); - continue; - } - if (!sectionType) { - flush(); - continue; - } - if (!raw.trim()) { - flush(); - continue; - } - const startsListItem = /^\s*(?:[-*+]|\d+[.)]|\[[ xX]\])\s+/.test(raw); - if (startsListItem) flush(); - const cleaned = raw - .replace(/^\s*[-*+]\s+/, '') - .replace(/^\s*\d+[.)]\s+/, '') - .replace(/^\s*\[[ xX]\]\s+/, '') - .trim(); - if (!cleaned) continue; - if (pending) pending.text = `${pending.text} ${cleaned}`; - else pending = { text: cleaned, line: index + 1, type: sectionType }; - } - flush(); - return output; -} - -function isCommunicationNoise(value: string): boolean { - const normalized = value.trim(); - return /^#{1,6}\s*\d+(?:[.):_-]\d+)*[.):_-]?\s*$/.test(normalized) - || /^(?:-{3,}|_{3,}|\*{3,})$/.test(normalized); -} - -function governanceSectionType(heading: string, role: Exclude): CommunicationType | null { - const normalized = heading.toLowerCase().replace(/[^a-z0-9ąćęłńóśźż]+/gi, ' ').trim(); - if (role === 'human') { - if (/\b(decision|decisions|decyzj|approval|zatwierdzen)/i.test(normalized)) return 'decision'; - if (/\b(instruction|instructions|request|requirements?|goal|scope|polecen|wymagan|zakres|cel)\b/i.test(normalized)) { - return 'request'; - } - return null; - } - if (/\b(actual changes?|result|results|report|unfinished|blockers?|wykonan|zmian|wynik|raport|blokad)\b/i.test(normalized)) { - return 'report'; - } - if (/\b(understanding|execution plan|plan|scope|guardrails?|risks?|hypotheses|code locations?|rozumien|zakres|ryzyk)\b/i.test(normalized)) { - return 'plan'; - } - if (/\b(approval|zatwierdzen)\b/i.test(normalized)) return 'claim'; - return null; -} - -function looksLikeTicket(value: string): boolean { - return /^[A-Za-z][A-Za-z0-9]*-\d+(?:[-_][A-Za-z0-9]+)*$/.test(value); -} - -function normalizeRole(value: string | null): CommunicationRole { - if (/^agents?$/i.test(value ?? '')) return 'agent'; - if (/^(human|humans|user|users|person)$/i.test(value ?? '')) return 'human'; - return 'unknown'; -} - -function normalizeType(value: string | null): CommunicationType { - const normalized = value?.toLowerCase(); - return isCommunicationType(normalized ?? '') ? normalized as CommunicationType : 'message'; -} - -function isCommunicationType(value: string): boolean { - return ['request', 'plan', 'decision', 'message', 'report', 'result', 'claim'].includes(value.toLowerCase()); -} - -function semanticsFor(type: CommunicationType, role: CommunicationRole): { lifecycle: LifecycleStatus; epistemicClass: EpistemicClass } { - if (type === 'plan') return { lifecycle: 'planned', epistemicClass: 'plan' }; - if (type === 'report' || type === 'result' || type === 'claim') return { lifecycle: 'implemented', epistemicClass: 'claim' }; - if (type === 'decision') return { lifecycle: 'completed', epistemicClass: 'declaration' }; - return { lifecycle: 'proposed', epistemicClass: role === 'agent' ? 'claim' : 'declaration' }; -} - -function first(...values: Array): string | null { - return values.find((value) => typeof value === 'string' && Boolean(value.trim()))?.trim() ?? null; -} - -function listValue(value: string | null): string[] { - if (!value) return []; - const jsonValues = jsonStringList(value); - if (jsonValues) return [...new Set(jsonValues.map((item) => item.trim()).filter(Boolean))].sort(); - const stripped = value.replace(/^\[|\]$/g, ''); - return [...new Set(stripped.split(',').map((item) => unquote(item.trim())).filter(Boolean))].sort(); -} - -function jsonStringList(value: string): string[] | null { - if (!value.startsWith('[') || !value.endsWith(']')) return null; - try { - const parsed: unknown = JSON.parse(value); - return Array.isArray(parsed) && parsed.every((item) => typeof item === 'string') - ? parsed : null; - } catch { - return null; - } -} - -function unquote(value: string): string { - return value.replace(/^['"]|['"]$/g, '').trim(); -} - -function validTimestamp(value: string | null): string | null { - if (!value) return null; - if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) { - return null; - } - const parsed = new Date(value); - return Number.isNaN(parsed.valueOf()) ? null : parsed.toISOString(); -} diff --git a/src/extractors/docs-record.ts b/src/extractors/docs-record.ts index bd7933b9..32ce2e04 100644 --- a/src/extractors/docs-record.ts +++ b/src/extractors/docs-record.ts @@ -22,6 +22,26 @@ const OBJECT_PLACEHOLDERS = new Set([ 'unknown', 'unspecified', 'none', 'null', 'n/a', 'na', '-', 'brak', 'nieznany', 'nieokreślony', ]); +function normalizedDocumentFields( + raw: RawDocumentRecord, + statementText: string, + object: string, + extraBasis: string[], +): Pick + & Pick + & { confidence: number; basis: string[] } { + return { + kind: raw.kind || 'documented_intent', + actor: raw.actor ?? null, + subject: raw.subject ?? null, + polarity: raw.polarity === 'negative' ? 'negative' : 'positive', + text: statementText || object, + lifecycle: { status: allowedLifecycle(raw.lifecycle) ? raw.lifecycle : 'proposed' }, + confidence: Math.min(0.85, Math.max(0.05, Number(raw.confidence) || 0.5)), + basis: [...new Set(['openrouter_structured_extraction', ...(raw.basis ?? []), ...extraBasis])], + }; +} + export function toDocumentIntentRecord( raw: RawDocumentRecord, chunk: DocumentChunk, @@ -36,26 +56,27 @@ export function toDocumentIntentRecord( const action = resolveAction(raw, statementText, extraBasis); const modality = resolveModality(raw, statementText, extraBasis); if (action === 'unknown') missingFields.push('action'); + const normalized = normalizedDocumentFields(raw, statementText, object, extraBasis); return buildRecord({ - kind: raw.kind || 'documented_intent', - actor: raw.actor ?? null, + kind: normalized.kind, + actor: normalized.actor, action, - subject: raw.subject ?? null, + subject: normalized.subject, object, target, modality, - polarity: raw.polarity === 'negative' ? 'negative' : 'positive', - text: statementText || object, - lifecycle: allowedLifecycle(raw.lifecycle) ? raw.lifecycle : 'proposed', + polarity: normalized.polarity, + text: normalized.text, + lifecycle: normalized.lifecycle.status, sourceKind: 'document', sourcePath: chunk.path, sourceLines: { start, end }, extractor: 't2c/document-openrouter@1', rawExcerpt: linesFromChunk(chunk, start, end), epistemicClass: 'llm_inference', - confidence: Math.min(0.85, Math.max(0.05, Number(raw.confidence) || 0.5)), - basis: [...new Set(['openrouter_structured_extraction', ...(raw.basis ?? []), ...extraBasis])], + confidence: normalized.confidence, + basis: normalized.basis, generation: { requested: 'llm', used: 'llm', provider: response.provider ?? 'openrouter', model: response.model ?? model, responseId: response.responseId, diff --git a/src/extractors/nl-llm.ts b/src/extractors/nl-llm.ts index 35673f3f..874459bf 100644 --- a/src/extractors/nl-llm.ts +++ b/src/extractors/nl-llm.ts @@ -172,32 +172,56 @@ function markDeterministic(records: IntentRecord[], degraded: boolean, fallbackR })); } -function toIntentRecord(raw: RawNlRecord, sourcePath: string, body: string, maxLine: number, config: T2CConfig, response: LlmResponseMetadata): IntentRecord { +function sourceEvidence( + raw: RawNlRecord, + body: string, + maxLine: number, +): { start: number; end: number; excerpt: string } { const start = clampLine(raw.sourceLines?.start ?? 1, 1, maxLine); const end = clampLine(raw.sourceLines?.end ?? start, start, maxLine); const lines = body.split(/\r?\n/); - const excerpt = lines.slice(start - 1, end).join('\n').slice(0, 2000); + return { start, end, excerpt: lines.slice(start - 1, end).join('\n').slice(0, 2000) }; +} + +function normalizeRawStatement(raw: RawNlRecord): { + action: IntentAction; + object: string; + text: string; + modality: Modality; + missingFields: string[]; +} { const action = allowedAction(raw.action) ? raw.action : 'unknown'; const { object, missingFields } = resolveObject(raw, action); const normalizedText = nonEmptyText(raw.text); if (normalizedText === null) missingFields.push('text'); - const statementText = normalizedText ?? object; + return { + action, + object, + text: normalizedText ?? object, + modality: allowedModality(raw.modality) ? raw.modality : 'unknown', + missingFields, + }; +} + +function toIntentRecord(raw: RawNlRecord, sourcePath: string, body: string, maxLine: number, config: T2CConfig, response: LlmResponseMetadata): IntentRecord { + const source = sourceEvidence(raw, body, maxLine); + const statement = normalizeRawStatement(raw); return buildRecord({ kind: raw.kind || 'declared_intent', actor: raw.actor ?? null, - action, + action: statement.action, subject: raw.subject ?? null, - object, + object: statement.object, target: raw.target, - modality: allowedModality(raw.modality) ? raw.modality : 'unknown', + modality: statement.modality, polarity: raw.polarity === 'negative' ? 'negative' : 'positive', - text: statementText, + text: statement.text, lifecycle: 'proposed', sourceKind: 'nl', sourcePath, - sourceLines: { start, end }, + sourceLines: { start: source.start, end: source.end }, extractor: 't2c/nl-openrouter@1', - rawExcerpt: excerpt || statementText, + rawExcerpt: source.excerpt || statement.text, epistemicClass: 'llm_inference', confidence: Math.min(0.9, Math.max(0.05, Number(raw.confidence) || 0.5)), basis: [...new Set(['openrouter_structured_extraction', ...(raw.basis ?? [])])], @@ -206,7 +230,7 @@ function toIntentRecord(raw: RawNlRecord, sourcePath: string, body: string, maxL model: response.model ?? config.openRouter.nlModel, responseId: response.responseId, }, metadata: { - missingFields, + missingFields: statement.missingFields, llmUsed: true, response, }, diff --git a/src/graph/linker.ts b/src/graph/linker.ts index e3243d01..b3fed51f 100644 --- a/src/graph/linker.ts +++ b/src/graph/linker.ts @@ -346,29 +346,16 @@ function scorePair( resolvableBasenames: Set, symbolResolutionIndex: SymbolResolutionIndex, ): PairEvidence { - let score = 0; - const basis: string[] = []; + const targetEvidence = scoreTargetEvidence( + left, + right, + resolvableBasenames, + symbolResolutionIndex, + ); + let score = targetEvidence.score; + const basis = [...targetEvidence.basis]; const leftKeywords = index.get(left.id); const rightKeywords = index.get(right.id); - if (intersects(left.statement.target.tickets, right.statement.target.tickets)) { - score += 0.62; - basis.push('shared_ticket'); - } - const resolvedNlAstSymbol = hasResolvedNlAstSymbolPair(left, right, symbolResolutionIndex); - if ((resolvedNlAstSymbol ?? intersectsAliases(left.statement.target.symbols, right.statement.target.symbols, symbolAliases))) { - score += 0.48; - basis.push('shared_symbol'); - } - if (pathsIntersect(left.statement.target.paths, right.statement.target.paths, resolvableBasenames)) { - score += 0.28; - basis.push('shared_path'); - if (isFileAggregateEvidencePair(left, right)) { - score += 0.24; - basis.push('module_coverage'); - const capabilityOverlap = aggregateCapabilityOverlap(left, right); - if (capabilityOverlap > 0) basis.push(`capability_overlap:${capabilityOverlap}`); - } - } if (left.statement.action === right.statement.action && left.statement.action !== 'unknown') { score += 0.13; basis.push('same_action'); @@ -397,6 +384,37 @@ function scorePair( return { score: Math.max(0, score), basis: [...new Set(basis)].sort(), textScore: objectSimilarity }; } +function scoreTargetEvidence( + left: IntentRecord, + right: IntentRecord, + resolvableBasenames: Set, + symbolResolutionIndex: SymbolResolutionIndex, +): { score: number; basis: string[] } { + let score = 0; + const basis: string[] = []; + if (intersects(left.statement.target.tickets, right.statement.target.tickets)) { + score += 0.62; + basis.push('shared_ticket'); + } + const resolvedNlAstSymbol = hasResolvedNlAstSymbolPair(left, right, symbolResolutionIndex); + if ((resolvedNlAstSymbol ?? intersectsAliases(left.statement.target.symbols, right.statement.target.symbols, symbolAliases))) { + score += 0.48; + basis.push('shared_symbol'); + } + if (!pathsIntersect(left.statement.target.paths, right.statement.target.paths, resolvableBasenames)) { + return { score, basis }; + } + score += 0.28; + basis.push('shared_path'); + if (isFileAggregateEvidencePair(left, right)) { + score += 0.24; + basis.push('module_coverage'); + const capabilityOverlap = aggregateCapabilityOverlap(left, right); + if (capabilityOverlap > 0) basis.push(`capability_overlap:${capabilityOverlap}`); + } + return { score, basis }; +} + function intersectionSize(left: Set, right: Set): number { const [small, large] = left.size <= right.size ? [left, right] : [right, left]; let size = 0; diff --git a/src/interfaces/a2a-card.ts b/src/interfaces/a2a-card.ts index 1156e34e..c903f065 100644 --- a/src/interfaces/a2a-card.ts +++ b/src/interfaces/a2a-card.ts @@ -141,6 +141,12 @@ function skills(): Array> { ['application/json'], ['application/json', 'image/svg+xml', 'text/markdown'], ), + ...reviewSkills(), + ]; +} + +function reviewSkills(): Array> { + return [ skill( 'review_todo_changes', 'Propose, review and apply TODO changes', diff --git a/src/interfaces/a2a-task-store.ts b/src/interfaces/a2a-task-store.ts index fc7c2d44..2580cba4 100644 --- a/src/interfaces/a2a-task-store.ts +++ b/src/interfaces/a2a-task-store.ts @@ -441,27 +441,68 @@ function agentMessage(task: StoredTask, text: string): A2AMessage { }; } -function listTasks(params: Record, principal: string): Record { +function taskListFilter(params: Record): { + contextId: string | undefined; + status: A2ATaskState | undefined; + statusTimestampAfter: string | undefined; + serialized: string; +} { const contextId = optionalString(params.contextId, 'contextId'); const status = optionalTaskState(params.status); - const pageSize = optionalInteger(params.pageSize, 'pageSize', 1, 100) ?? 50; - const historyLength = optionalInteger(params.historyLength, 'historyLength', 0, 100_000); - const includeArtifacts = optionalBoolean(params.includeArtifacts, 'includeArtifacts') ?? false; const statusTimestampAfter = optionalTimestamp(params.statusTimestampAfter, 'statusTimestampAfter'); - const filter = JSON.stringify({ - contextId: contextId ?? null, - status: status ?? null, - statusTimestampAfter: statusTimestampAfter ?? null, - }); - const filtered = filteredTasks(principal, contextId, status, statusTimestampAfter); + return { + contextId, + status, + statusTimestampAfter, + serialized: JSON.stringify({ + contextId: contextId ?? null, + status: status ?? null, + statusTimestampAfter: statusTimestampAfter ?? null, + }), + }; +} + +function taskListPageOptions(params: Record): { + pageSize: number; + historyLength: number | undefined; + includeArtifacts: boolean; +} { + return { + pageSize: optionalInteger(params.pageSize, 'pageSize', 1, 100) ?? 50, + historyLength: optionalInteger(params.historyLength, 'historyLength', 0, 100_000), + includeArtifacts: optionalBoolean(params.includeArtifacts, 'includeArtifacts') ?? false, + }; +} + +function taskPageStart(params: Record, filtered: StoredTask[], filter: string): number { const pageCursor = optionalString(params.pageToken, 'pageToken'); - const start = pageCursor ? indexAfterCursor(filtered, decodeCursor(pageCursor, filter)) : 0; - const page = filtered.slice(start, start + pageSize); + return pageCursor ? indexAfterCursor(filtered, decodeCursor(pageCursor, filter)) : 0; +} + +function nextTaskPageToken( + start: number, + page: StoredTask[], + totalSize: number, + filter: string, +): string { const last = page.at(-1); + return start + page.length < totalSize && last ? encodeCursor(last, filter) : ''; +} + +function listTasks(params: Record, principal: string): Record { + const filter = taskListFilter(params); + const options = taskListPageOptions(params); + const filtered = filteredTasks(principal, filter.contextId, filter.status, filter.statusTimestampAfter); + const start = taskPageStart(params, filtered, filter.serialized); + const page = filtered.slice(start, start + options.pageSize); return { - tasks: page.map((task) => taskView(task, { includeArtifacts, historyLength, defaultHistoryLength: 0 })), - nextPageToken: start + page.length < filtered.length && last ? encodeCursor(last, filter) : '', - pageSize, + tasks: page.map((task) => taskView(task, { + includeArtifacts: options.includeArtifacts, + historyLength: options.historyLength, + defaultHistoryLength: 0, + })), + nextPageToken: nextTaskPageToken(start, page, filtered.length, filter.serialized), + pageSize: options.pageSize, totalSize: filtered.length, }; } diff --git a/src/live/contract-check.ts b/src/live/contract-check.ts index feef56f9..10b5fb63 100644 --- a/src/live/contract-check.ts +++ b/src/live/contract-check.ts @@ -112,20 +112,25 @@ export function missingLiveStages(manifest: PipelineManifest): LiveStageName[] { return LIVE_STAGE_NAMES.filter((stage) => !manifest.stages?.[stage]); } -function measureStage( - stage: LiveStageName, - audit: PipelineStageAudit, - budget: LiveBudget, -): LiveStageMeasurement { - const responses = audit.responses ?? []; +function stageFailures(audit: PipelineStageAudit, responseCount: number): string[] { const failures: string[] = []; if (audit.status !== 'succeeded') failures.push(`status=${audit.status}`); // A deterministic fallback that still "succeeds" is exactly the silent // degradation this check exists to catch. if (audit.effectiveMode !== 'llm') failures.push(`effectiveMode=${audit.effectiveMode}`); if (audit.degraded) failures.push('degraded=true'); - if (responses.length === 0) failures.push('no LLM response metadata'); + if (responseCount === 0) failures.push('no LLM response metadata'); if (audit.reason) failures.push(redactLiveMessage(`${audit.reason.code}: ${audit.reason.message}`)); + return failures; +} + +function measureStage( + stage: LiveStageName, + audit: PipelineStageAudit, + budget: LiveBudget, +): LiveStageMeasurement { + const responses = audit.responses ?? []; + const failures = stageFailures(audit, responses.length); const overLatency = audit.durationMs > budget.maxStageLatencyMs; return { diff --git a/src/llm/openrouter-parse.ts b/src/llm/openrouter-parse.ts new file mode 100644 index 00000000..ccdcd60c --- /dev/null +++ b/src/llm/openrouter-parse.ts @@ -0,0 +1,122 @@ +import type { LlmResponseMetadata } from '../core/types.js'; +import { StructuredResponseError } from './structured-schema.js'; +import { redactProviderFailureText } from './openrouter-redact.js'; + +export interface OpenRouterChoice { + message?: { + content?: string | Array<{ type?: string; text?: string }>; + }; +} + +export interface OpenRouterResponse { + id?: string; + model?: string; + provider?: string; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + cost?: number; + }; + choices?: OpenRouterChoice[]; + error?: { message?: string }; +} + +export interface OpenRouterResult { + value: T; + metadata: LlmResponseMetadata; +} + +export function parseProviderResponse( + text: string, + status: number, + credential: string, + providerLabel: string, +): OpenRouterResponse { + try { + return JSON.parse(text) as OpenRouterResponse; + } catch { + throw new Error( + `${providerLabel} returned non-JSON HTTP ${status}: ` + + redactProviderFailureText(text.slice(0, 500), credential), + ); + } +} + +export function extractContent(response: OpenRouterResponse): string { + const content = response.choices?.[0]?.message?.content; + if (typeof content === 'string') return content; + if (Array.isArray(content)) return content.map((part) => part.text ?? '').join(''); + throw new Error(`${responseProviderLabel(response)} response does not contain choices[0].message.content`); +} + +export function parseJsonContent(content: string, providerLabel: string): T { + const trimmed = content.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); + try { + return JSON.parse(trimmed) as T; + } catch (error) { + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + if (start >= 0 && end > start) { + try { + return JSON.parse(trimmed.slice(start, end + 1)) as T; + } catch { + // Fall through to the detailed error below. + } + } + throw new Error(`${providerLabel} JSON parsing failed: ${error instanceof Error ? error.message : String(error)}; response=${trimmed.slice(0, 500)}`); + } +} + +export function parseJsonResponse(response: OpenRouterResponse): OpenRouterResult { + const metadata = responseMetadata(response); + try { + return { value: parseJsonContent(extractContent(response), responseProviderLabel(response)), metadata }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new StructuredResponseError(message, metadata); + } +} + +export function responseMetadata(response: OpenRouterResponse): LlmResponseMetadata { + const usage = response.usage; + return { + responseId: stringOrNull(response.id), + model: stringOrNull(response.model), + provider: stringOrNull(response.provider), + usage: usage ? { + promptTokens: finiteOrNull(usage.prompt_tokens), + completionTokens: finiteOrNull(usage.completion_tokens), + totalTokens: finiteOrNull(usage.total_tokens), + cost: finiteOrNull(usage.cost), + } : null, + }; +} + +export function responseProviderLabel(response: OpenRouterResponse): string { + if (response.provider === 'zai') return 'Z.AI'; + if (response.provider === 'openrouter') return 'OpenRouter'; + return 'LLM'; +} + +export function isInvalidModelError(status: number, message: string): boolean { + return status === 400 && /(?:not a valid model ID|invalid model(?: ID)?|model ID .*not found)/i.test(message); +} + +export function formatInvalidModelError( + message: string, + availableModels: string[], + providerLabel: string, +): string { + const heading = `Available ${providerLabel} models (${availableModels.length}):`; + if (!availableModels.length) return `${message}\n${heading}\n(none returned)`; + return `${message}\n${heading}\n${availableModels.map((model) => `- ${model}`).join('\n')}`; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function finiteOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} diff --git a/src/llm/openrouter-redact.ts b/src/llm/openrouter-redact.ts new file mode 100644 index 00000000..1e00e428 --- /dev/null +++ b/src/llm/openrouter-redact.ts @@ -0,0 +1,27 @@ +const BEARER_CREDENTIAL_RE = new RegExp('\\bBearer\\s+[A-Za-z0-9._~-]{8,}', 'giu'); +const OPENROUTER_CREDENTIAL_RE = /\bsk-or-v1-[A-Za-z0-9_-]+/gu; +const SECRET_ASSIGNMENT_RE = new RegExp( + '\\b((?:api|access)[-_\\s]?key|client[-_\\s]?secret|token|password)\\s*[:=#]\\s*[A-Za-z0-9_./+=~-]{12,}\\b', + 'giu', +); +const PROVIDER_MANAGEMENT_URL_RE = /https?:\/\/[^\s<>"']*(?:\/(?:keys?|credentials?)(?:\/|[?#]|$))[^\s<>"']*/giu; +const CREDENTIAL_IDENTIFIER_RE = new RegExp( + '\\b((?:api[-_\\s]?key|credential|key)[-_\\s]?(?:id|fingerprint))\\s*[:=#]?\\s*[A-Za-z0-9_-]{20,}\\b', + 'giu', +); + +/** + * Provider error bodies are untrusted external text. Keep their useful status + * explanation, but never let a credential, stable credential identifier or + * account-management URL cross the common LLM boundary. + */ +export function redactProviderFailureText(message: string, configuredCredential: string | null): string { + let redacted = message; + if (configuredCredential) redacted = redacted.split(configuredCredential).join('[redacted-credential]'); + return redacted + .replace(BEARER_CREDENTIAL_RE, 'Bearer [redacted-credential]') + .replace(OPENROUTER_CREDENTIAL_RE, '[redacted-credential]') + .replace(SECRET_ASSIGNMENT_RE, '$1=[redacted-credential]') + .replace(PROVIDER_MANAGEMENT_URL_RE, '[redacted-provider-management-url]') + .replace(CREDENTIAL_IDENTIFIER_RE, '$1 [redacted-credential-id]'); +} diff --git a/src/llm/openrouter.ts b/src/llm/openrouter.ts index 6f475f48..a7767d9b 100644 --- a/src/llm/openrouter.ts +++ b/src/llm/openrouter.ts @@ -1,51 +1,27 @@ import { randomUUID } from 'node:crypto'; import type { T2CConfig } from '../config/env.js'; -import type { LlmResponseMetadata } from '../core/types.js'; import { StructuredResponseError, type StructuredSchema } from './structured-schema.js'; import { openRouterRequestTimeout, type OpenRouterTimeoutDecision } from './openrouter-timeout.js'; +import { + extractContent, + formatInvalidModelError, + isInvalidModelError, + parseJsonResponse, + parseProviderResponse, + responseMetadata, + responseProviderLabel, + type OpenRouterResponse, + type OpenRouterResult, +} from './openrouter-parse.js'; +import { redactProviderFailureText } from './openrouter-redact.js'; import { resolveSubllmRoute, shouldUseSubllm, type ResolvedSubllmRoute } from './subllm.js'; -const BEARER_CREDENTIAL_RE = new RegExp('\\bBearer\\s+[A-Za-z0-9._~-]{8,}', 'giu'); -const OPENROUTER_CREDENTIAL_RE = /\bsk-or-v1-[A-Za-z0-9_-]+/gu; -const SECRET_ASSIGNMENT_RE = new RegExp( - '\\b((?:api|access)[-_\\s]?key|client[-_\\s]?secret|token|password)\\s*[:=#]\\s*[A-Za-z0-9_./+=~-]{12,}\\b', - 'giu', -); -const PROVIDER_MANAGEMENT_URL_RE = /https?:\/\/[^\s<>"']*(?:\/(?:keys?|credentials?)(?:\/|[?#]|$))[^\s<>"']*/giu; -const CREDENTIAL_IDENTIFIER_RE = new RegExp( - '\\b((?:api[-_\\s]?key|credential|key)[-_\\s]?(?:id|fingerprint))\\s*[:=#]?\\s*[A-Za-z0-9_-]{20,}\\b', - 'giu', -); - export interface ChatMessage { role: 'system' | 'user' | 'assistant'; content: string; } -interface OpenRouterChoice { - message?: { - content?: string | Array<{ type?: string; text?: string }>; - }; -} - -interface OpenRouterResponse { - id?: string; - model?: string; - provider?: string; - usage?: { - prompt_tokens?: number; - completion_tokens?: number; - total_tokens?: number; - cost?: number; - }; - choices?: OpenRouterChoice[]; - error?: { message?: string }; -} - -export interface OpenRouterResult { - value: T; - metadata: LlmResponseMetadata; -} +export type { OpenRouterResult } from './openrouter-parse.js'; interface OpenRouterModelsResponse { data?: Array<{ id?: string }>; @@ -241,8 +217,12 @@ export class OpenRouterClient { ): Promise { let lastError: Error | null = null; for (let attempt = 0; attempt < 3; attempt += 1) { + const attemptController = new AbortController(); + const abortAttempt = () => attemptController.abort(); + signal.addEventListener('abort', abortAttempt, { once: true }); + if (signal.aborted) attemptController.abort(); try { - return await this.requestAttempt(body, transport, signal); + return await this.requestAttempt(body, transport, attemptController.signal); } catch (caught) { const error = normalizeRequestError(caught, externalSignal, timeoutDecision, transport.providerLabel); lastError = error; @@ -250,6 +230,8 @@ export class OpenRouterClient { await waitForRetry( 300 * (2 ** attempt), signal, externalSignal, timeoutDecision, transport.providerLabel, ); + } finally { + signal.removeEventListener('abort', abortAttempt); } } throw lastError ?? new Error(`${transport.providerLabel} request failed`); @@ -382,38 +364,6 @@ function providerRequestBody(body: Record, transport: LlmTransp }) as Record; } -function parseProviderResponse( - text: string, - status: number, - credential: string, - providerLabel: string, -): OpenRouterResponse { - try { - return JSON.parse(text) as OpenRouterResponse; - } catch { - throw new Error( - `${providerLabel} returned non-JSON HTTP ${status}: ` - + redactProviderFailureText(text.slice(0, 500), credential), - ); - } -} - -/** - * Provider error bodies are untrusted external text. Keep their useful status - * explanation, but never let a credential, stable credential identifier or - * account-management URL cross the common LLM boundary. - */ -function redactProviderFailureText(message: string, configuredCredential: string | null): string { - let redacted = message; - if (configuredCredential) redacted = redacted.split(configuredCredential).join('[redacted-credential]'); - return redacted - .replace(BEARER_CREDENTIAL_RE, 'Bearer [redacted-credential]') - .replace(OPENROUTER_CREDENTIAL_RE, '[redacted-credential]') - .replace(SECRET_ASSIGNMENT_RE, '$1=[redacted-credential]') - .replace(PROVIDER_MANAGEMENT_URL_RE, '[redacted-provider-management-url]') - .replace(CREDENTIAL_IDENTIFIER_RE, '$1 [redacted-credential-id]'); -} - function normalizeRequestError( caught: unknown, externalSignal: AbortSignal | undefined, @@ -448,48 +398,11 @@ async function waitForRetry( } } -function responseMetadata(response: OpenRouterResponse): LlmResponseMetadata { - const usage = response.usage; - return { - responseId: stringOrNull(response.id), - model: stringOrNull(response.model), - provider: stringOrNull(response.provider), - usage: usage ? { - promptTokens: finiteOrNull(usage.prompt_tokens), - completionTokens: finiteOrNull(usage.completion_tokens), - totalTokens: finiteOrNull(usage.total_tokens), - cost: finiteOrNull(usage.cost), - } : null, - }; -} - -function stringOrNull(value: unknown): string | null { - return typeof value === 'string' && value.trim() ? value.trim() : null; -} - -function finiteOrNull(value: unknown): number | null { - return typeof value === 'number' && Number.isFinite(value) ? value : null; -} - function shouldRetryWithoutJsonSchema(error: unknown): boolean { if (!(error instanceof Error)) return false; return /OpenRouter HTTP 4\d\d:|OpenRouter returned non-JSON|response does not contain choices|returned invalid JSON/i.test(error.message); } -function isInvalidModelError(status: number, message: string): boolean { - return status === 400 && /(?:not a valid model ID|invalid model(?: ID)?|model ID .*not found)/i.test(message); -} - -function formatInvalidModelError( - message: string, - availableModels: string[], - providerLabel: LlmTransport['providerLabel'], -): string { - const heading = `Available ${providerLabel} models (${availableModels.length}):`; - if (!availableModels.length) return `${message}\n${heading}\n(none returned)`; - return `${message}\n${heading}\n${availableModels.map((model) => `- ${model}`).join('\n')}`; -} - function removeUndefined(value: unknown): unknown { if (Array.isArray(value)) return value.map(removeUndefined); if (value !== null && typeof value === 'object') { @@ -504,47 +417,6 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } -function extractContent(response: OpenRouterResponse): string { - const content = response.choices?.[0]?.message?.content; - if (typeof content === 'string') return content; - if (Array.isArray(content)) return content.map((part) => part.text ?? '').join(''); - throw new Error(`${responseProviderLabel(response)} response does not contain choices[0].message.content`); -} - -function parseJsonContent(content: string, providerLabel: string): T { - const trimmed = content.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); - try { - return JSON.parse(trimmed) as T; - } catch (error) { - const start = trimmed.indexOf('{'); - const end = trimmed.lastIndexOf('}'); - if (start >= 0 && end > start) { - try { - return JSON.parse(trimmed.slice(start, end + 1)) as T; - } catch { - // Fall through to the detailed error below. - } - } - throw new Error(`${providerLabel} JSON parsing failed: ${error instanceof Error ? error.message : String(error)}; response=${trimmed.slice(0, 500)}`); - } -} - -function parseJsonResponse(response: OpenRouterResponse): OpenRouterResult { - const metadata = responseMetadata(response); - try { - return { value: parseJsonContent(extractContent(response), responseProviderLabel(response)), metadata }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new StructuredResponseError(message, metadata); - } -} - -function responseProviderLabel(response: OpenRouterResponse): string { - if (response.provider === 'zai') return 'Z.AI'; - if (response.provider === 'openrouter') return 'OpenRouter'; - return 'LLM'; -} - function sleep(milliseconds: number, signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(new DOMException('aborted', 'AbortError')); return new Promise((resolve, reject) => { diff --git a/src/pipeline/pipeline-run.ts b/src/pipeline/pipeline-run.ts new file mode 100644 index 00000000..2b3bf89f --- /dev/null +++ b/src/pipeline/pipeline-run.ts @@ -0,0 +1,567 @@ +import path from 'node:path'; +import type { T2CConfig } from '../config/env.js'; +import { hasOpenRouter } from '../config/env.js'; +import { addCommunicationIssuesToDiagnostics, analyzeCommunication, renderCommunicationMarkdown } from '../communication/analyzer.js'; +import { CommunicationLlmRequiredError, extractCommunicationIntentAudited } from '../communication/llm.js'; +import { createIntentId, newRunId } from '../core/id.js'; +import { ensureDir, pathExists, readText, resolveGlobs, writeJson, writeJsonl, writeText } from '../core/io.js'; +import type { + Diagnostic, + DiagnosticReport, + IntentRecord, + PipelineManifest, + PipelineFailureStage, + PipelineOptions, + PipelineStageAudit, +} from '../core/types.js'; +import { extractAstIntent } from '../extractors/ast.js'; +import { extractConfigurationIntent } from '../extractors/configuration.js'; +import { extractRuntimeCycleIntent } from '../extractors/runtime-cycle.js'; +import { DocumentationLlmRequiredError, extractDocumentationIntent } from '../extractors/docs-llm.js'; +import { extractDocumentationBaseline } from '../extractors/docs-deterministic.js'; +import { extractGitIntent } from '../extractors/git.js'; +import { extractMarkdownIntentAudited } from '../extractors/markdown-llm.js'; +import { extractNlIntentAudited } from '../extractors/nl-llm.js'; +import { openRouterAuditConfiguration } from '../llm/audit.js'; +import { diagnoseGraph } from '../graph/diagnostics.js'; +import { linkIntentRecords } from '../graph/linker.js'; +import { summarizeGraph } from '../summary/summarizer.js'; +import { + createCodeChangeReviewPatch, + createCodeChangeSourcePatchSet, + createRepositoryPathProbe, + proposeCodeChangePlans, +} from '../synthesis/code-change-plan.js'; +import { synthesizeTodoProposals, type AuditedTaskSynthesisResult } from '../synthesis/tasks-llm.js'; +import { createTodoPatch, type CreatedTodoPatch } from '../synthesis/todo-patch.js'; +import { T2C_VERSION } from '../version.js'; +import { persistPipelineEventLog } from './event-log-persistence.js'; + +import { + appendLlmNotConfigured, + collectTargetHints, + manifestConfiguration, + persistFailedRun, + skippedAudit, +} from './run-helpers.js'; +import type { + AnalysisResult, + CodeChangePlansResult, + CodeChangeReviewResult, + CodeChangeSourcePatchesResult, + CommunicationAnalysisResult, + ExtractionResult, + OutputPaths, + PipelineResult, + PipelineSummaryResult, + PlanningResult, + SummaryResult, + SynthesisResult, +} from './run-types.js'; + +class PipelineRun { + private readonly root: string; + private readonly runId = newRunId(); + private readonly baseOutput: string; + private readonly runDirectory: string; + private activeStage: PipelineFailureStage = 'setup'; + private readonly completedStages: Partial = {}; + private readonly warnings: string[] = []; + private readonly bySource: Record = { + nl: [], git: [], ast: [], todo: [], changelog: [], document: [], + configuration: [], runtime: [], communication: [], + }; + + constructor(private readonly options: PipelineOptions, private readonly config: T2CConfig) { + this.root = path.resolve(options.root); + this.baseOutput = path.resolve(this.root, options.outputDir); + this.runDirectory = path.join(this.baseOutput, 'runs', this.runId); + } + + async run(): Promise { + if (!(await pathExists(this.root))) throw new Error(`Root does not exist: ${this.root}`); + await ensureDir(this.runDirectory); + try { + return await this.execute(); + } catch (error) { + await persistFailedRun( + this.runId, this.root, this.runDirectory, this.options, this.config, + error, this.activeStage, this.completedStages, + ); + throw error; + } + } + + private async execute(): Promise { + const extraction = await this.extractSources(); + const analysis = this.analyze(extraction); + const synthesis = await this.synthesizeTasks(analysis); + const planning = this.planChanges(analysis, synthesis); + const summary = await this.summarize(analysis); + return this.persist(extraction, analysis, synthesis, planning, summary); + } + + private async extractSources(): Promise { + const naturalLanguageAudit = await this.extractNaturalLanguage(); + this.activeStage = 'gitExtraction'; + const git = await extractGitIntent({ root: this.root, count: this.options.gitCommitCount }, this.config); + this.bySource.git = git.records; + this.warnings.push(...git.warnings); + this.activeStage = 'astExtraction'; + const ast = await extractAstIntent({ root: this.root }, this.config); + this.bySource.ast = ast.records; + this.warnings.push(...ast.warnings); + const markdownAudit = await this.extractMarkdown(); + const documentationAudit = await this.extractDocumentation(); + await this.extractConfigurationAndRuntime(); + const communication = await this.extractCommunication(); + return { naturalLanguageAudit, markdownAudit, documentationAudit, ...communication }; + } + + private async extractNaturalLanguage(): Promise { + let audit = skippedAudit('disabled', 'No NL task file was selected'); + if (this.options.taskFile) { + this.activeStage = 'naturalLanguageExtraction'; + const result = await extractNlIntentAudited( + { root: this.root, sourcePath: this.options.taskFile }, + this.config, + this.options.nlMode ?? this.config.nlMode, + ); + this.bySource.nl = result.records; + this.warnings.push(...result.warnings); + audit = result.audit; + } + this.completedStages.naturalLanguageExtraction = audit; + return audit; + } + + private async extractMarkdown(): Promise { + this.activeStage = 'markdownExtraction'; + const markdown = await extractMarkdownIntentAudited( + { root: this.root, todoPath: this.options.todoFile, changelogPath: this.options.changelogFile }, + this.config, + this.options.markdownMode ?? this.config.markdownMode, + ); + this.bySource.todo = markdown.records.filter((record) => record.source.kind === 'todo'); + this.bySource.changelog = markdown.records.filter((record) => record.source.kind === 'changelog'); + this.warnings.push(...markdown.warnings); + this.completedStages.markdownExtraction = markdown.audit; + return markdown.audit; + } + + private async extractDocumentation(): Promise { + this.activeStage = 'documentationExtraction'; + const files = await resolveGlobs( + this.root, this.options.documentPatterns, + this.options.documentExcludes ?? this.config.documentExcludes, + ); + const startedAt = Date.now(); + const deterministic = await extractDocumentationBaseline({ root: this.root, files }, this.config); + this.bySource.document = deterministic.records; + this.warnings.push(...deterministic.warnings); + let audit = this.deterministicDocumentationAudit(files.length, deterministic, startedAt); + if (this.options.includeDocumentationLlm) { + audit = hasOpenRouter(this.config) + ? await this.enrichDocumentation(deterministic.records.length) + : this.missingDocumentationLlmAudit(deterministic.records.length); + } + this.completedStages.documentationExtraction = audit; + return audit; + } + + private deterministicDocumentationAudit( + fileCount: number, + result: Awaited>, + startedAt: number, + ): PipelineStageAudit { + if (fileCount === 0) return skippedAudit('deterministic', 'No documentation files matched the configured patterns'); + return { + runtimeVersion: T2C_VERSION, + configuration: { generator: 't2c/markdown-documentation', generatorVersion: '2' }, + status: result.warnings.length ? 'partial' : 'succeeded', + requestedMode: 'deterministic', effectiveMode: 'deterministic', + degraded: result.warnings.length > 0, + recordCount: result.records.length, warningCount: result.warnings.length, + model: null, durationMs: Date.now() - startedAt, + reason: result.warnings.length + ? { code: 'DOCUMENT_EXTRACTION_PARTIAL', message: `${result.warnings.length} deterministic documentation warning(s)` } + : null, + responses: [], + }; + } + + private async enrichDocumentation(deterministicRecordCount: number): Promise { + this.activeStage = 'documentationExtraction'; + const docs = await extractDocumentationIntent({ + root: this.root, + patterns: this.options.documentPatterns, + excludes: this.options.documentExcludes ?? this.config.documentExcludes, + targetHints: collectTargetHints(Object.values(this.bySource).flat()), + }, this.config); + this.bySource.document!.push(...docs.records); + this.warnings.push(...docs.warnings); + return { + ...docs.audit, + recordCount: this.bySource.document!.length, + configuration: { + ...docs.audit.configuration, + deterministicGenerator: 't2c/markdown-documentation@2', + deterministicRecordCount, + }, + }; + } + + private missingDocumentationLlmAudit(deterministicRecordCount: number): PipelineStageAudit { + const message = 'OPENROUTER_API_KEY is not configured; documentation -> Intent DSL was skipped'; + this.warnings.push(message); + return { + ...skippedAudit('llm', message), + configuration: openRouterAuditConfiguration( + this.config, this.config.openRouter.documentModel, this.config.documentTimeoutMs, + ), + status: deterministicRecordCount ? 'fallback' : 'failed', + effectiveMode: deterministicRecordCount ? 'deterministic' : 'none', + degraded: true, recordCount: deterministicRecordCount, + model: this.config.openRouter.documentModel, + reason: { code: 'LLM_NOT_CONFIGURED', message }, responses: [], + }; + } + + private async extractConfigurationAndRuntime(): Promise { + this.activeStage = 'configurationExtraction'; + const configuration = await extractConfigurationIntent(this.root, this.config); + this.bySource.configuration = configuration.records; + this.warnings.push(...configuration.warnings); + this.activeStage = 'runtimeExtraction'; + if (!this.options.cycleFile) return; + try { + const runtime = await extractRuntimeCycleIntent(this.options.cycleFile, this.config, this.root); + this.bySource.runtime = runtime.records; + this.warnings.push(...runtime.warnings); + } catch (error) { + this.warnings.push(`runtime cycle ignored: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private async extractCommunication(): Promise> { + let communicationAudit = skippedAudit('disabled', 'Communication analysis was disabled'); + if (this.options.includeCommunication === false) { + this.completedStages.communicationAnalysis = communicationAudit; + return { communicationAudit, communicationInputPresent: false, communicationSyntheses: [] }; + } + this.activeStage = 'communicationAnalysis'; + const startedAt = Date.now(); + const communication = await extractCommunicationIntentAudited({ + root: this.root, + projectDir: this.options.projectDirectory ?? 'project', + ticket: this.options.communicationTicket ?? null, + }, this.config, this.options.communicationMode ?? this.config.communicationMode); + const missing = communication.records.length === 0 && communication.warnings.length === 1 + && communication.warnings[0]?.startsWith('Communication directory not found:'); + this.bySource.communication = communication.records; + if (!missing) this.warnings.push(...communication.warnings); + communicationAudit = missing + ? skippedAudit('deterministic', communication.warnings[0] ?? 'Communication directory not found') + : { ...communication.audit, durationMs: Date.now() - startedAt }; + this.completedStages.communicationAnalysis = communicationAudit; + return { + communicationAudit, + communicationInputPresent: !missing, + communicationSyntheses: communication.participants, + }; + } + + private analyze(extraction: ExtractionResult): AnalysisResult { + this.activeStage = 'linking'; + const generatedAt = new Date().toISOString(); + const graph = linkIntentRecords(Object.values(this.bySource).flat(), generatedAt); + this.activeStage = 'diagnostics'; + const communicationAnalysis = extraction.communicationInputPresent + ? analyzeCommunication(graph, generatedAt, extraction.communicationSyntheses) + : null; + let diagnostics = diagnoseGraph(graph, generatedAt); + if (communicationAnalysis) diagnostics = addCommunicationIssuesToDiagnostics(diagnostics, communicationAnalysis); + if (this.options.includeDocumentationLlm && !hasOpenRouter(this.config)) appendLlmNotConfigured(diagnostics); + return { generatedAt, graph, diagnostics, communicationAnalysis }; + } + + private async synthesizeTasks(analysis: AnalysisResult): Promise { + const mode = this.options.taskSynthesisMode ?? 'disabled'; + let taskSynthesis: AuditedTaskSynthesisResult | null = null; + let todoPatch: CreatedTodoPatch | null = null; + let audit = skippedAudit('disabled', 'Task synthesis was disabled'); + if (mode !== 'disabled') { + this.activeStage = 'taskSynthesis'; + taskSynthesis = await synthesizeTodoProposals(analysis.graph, analysis.diagnostics, this.config, mode); + this.warnings.push(...taskSynthesis.warnings); + audit = taskSynthesis.audit; + this.completedStages.taskSynthesis = audit; + if (!this.options.todoFile) throw new Error('Task synthesis rendering requires a TODO source file'); + this.activeStage = 'todoRendering'; + const todoPath = path.resolve(this.root, this.options.todoFile); + const todoContent = await readText(todoPath, this.config.maxFileBytes); + todoPatch = createTodoPatch({ + todoPath: path.relative(this.root, todoPath).replace(/\\/g, '/'), todoContent, + graph: analysis.graph, diagnostics: analysis.diagnostics, + conclusions: taskSynthesis.conclusions, proposals: taskSynthesis.proposals, + validation: taskSynthesis.validation, synthesisAudit: taskSynthesis.audit, + }); + } + this.completedStages.taskSynthesis = audit; + return { taskSynthesis, todoPatch, audit }; + } + + private planChanges(analysis: AnalysisResult, synthesis: SynthesisResult): PlanningResult { + this.activeStage = 'codeChangePlanning'; + const plans = proposeCodeChangePlans({ + graph: analysis.graph, diagnostics: analysis.diagnostics, + ...(synthesis.taskSynthesis ? { + conclusions: synthesis.taskSynthesis.conclusions, + proposals: synthesis.taskSynthesis.proposals, + } : {}), + generatedAt: analysis.generatedAt, + pathExists: createRepositoryPathProbe(this.root), + }); + const review = createCodeChangeReviewPatch({ + plans: plans.plans, graphFingerprint: analysis.graph.fingerprint, + createdAt: analysis.generatedAt, + }); + const sourcePatches = createCodeChangeSourcePatchSet({ + plans: plans.plans, graphFingerprint: analysis.graph.fingerprint, + generatedAt: analysis.generatedAt, + }); + const audit: PipelineStageAudit = { + runtimeVersion: T2C_VERSION, configuration: openRouterAuditConfiguration(this.config, null), + status: 'succeeded', requestedMode: 'deterministic', effectiveMode: 'deterministic', + degraded: false, recordCount: plans.plans.length, warningCount: 0, + model: null, durationMs: 0, reason: null, responses: [], + }; + this.completedStages.codeChangePlanning = audit; + return { plans, review, sourcePatches, audit }; + } + + private async summarize(analysis: AnalysisResult): Promise { + this.activeStage = 'summary'; + const startedAt = Date.now(); + const includeLlm = this.options.includeSummaryLlm !== false; + const summary = await summarizeGraph(analysis.graph, analysis.diagnostics, this.config, { + allowDeterministicFallback: this.options.allowSummaryFallback, + preferLlm: includeLlm, + }); + this.warnings.push(...summary.warnings); + let audit: PipelineStageAudit; + if (!includeLlm) audit = this.disabledSummaryAudit(summary, startedAt); + else if (summary.llmUsed) audit = this.successfulSummaryAudit(summary, startedAt); + else audit = this.fallbackSummaryAudit(summary, startedAt); + this.completedStages.summary = audit; + return { summary, audit }; + } + + private disabledSummaryAudit(summary: SummaryResult, startedAt: number): PipelineStageAudit { + return { + runtimeVersion: T2C_VERSION, configuration: openRouterAuditConfiguration(this.config, null), + status: 'skipped', requestedMode: 'deterministic', effectiveMode: 'deterministic', degraded: false, + recordCount: summary.conclusions.length, warningCount: 0, model: null, + durationMs: Date.now() - startedAt, + reason: { code: 'LLM_DISABLED', message: 'LLM summary was disabled; generated the deterministic report' }, + responses: [], + }; + } + + private successfulSummaryAudit(summary: SummaryResult, startedAt: number): PipelineStageAudit { + return { + runtimeVersion: T2C_VERSION, + configuration: openRouterAuditConfiguration(this.config, this.config.openRouter.summaryModel), + status: 'succeeded', requestedMode: 'llm', effectiveMode: 'llm', degraded: false, + recordCount: summary.conclusions.length, warningCount: summary.warnings.length, + model: this.config.openRouter.summaryModel, durationMs: Date.now() - startedAt, + reason: null, responses: summary.responses, + }; + } + + private fallbackSummaryAudit(summary: SummaryResult, startedAt: number): PipelineStageAudit { + return { + runtimeVersion: T2C_VERSION, + configuration: openRouterAuditConfiguration(this.config, this.config.openRouter.summaryModel), + status: 'fallback', requestedMode: 'llm', effectiveMode: 'deterministic', degraded: true, + recordCount: summary.conclusions.length, warningCount: summary.warnings.length, + model: this.config.openRouter.summaryModel, durationMs: Date.now() - startedAt, + reason: { + code: hasOpenRouter(this.config) ? 'LLM_UNAVAILABLE' : 'LLM_NOT_CONFIGURED', + message: summary.warnings[0] ?? 'Deterministic summary fallback was used', + }, + responses: [], + }; + } + + private async persist( + extraction: ExtractionResult, + analysis: AnalysisResult, + synthesis: SynthesisResult, + planning: PlanningResult, + summary: PipelineSummaryResult, + ): Promise { + this.activeStage = 'persistence'; + const files: Record = {}; + await this.writeIntentFiles(files); + const paths = this.outputPaths(synthesis, analysis.communicationAnalysis); + await this.writeRequiredOutputs(paths, analysis, planning, summary, files); + await this.writeOptionalOutputs(paths, analysis.communicationAnalysis, synthesis, files); + const manifest = this.createManifest(files, extraction, analysis, synthesis, planning, summary); + await writeJson(path.join(this.runDirectory, 'manifest.json'), manifest); + await persistPipelineEventLog({ root: this.root, runDirectory: this.runDirectory, manifest }); + await writeJson(path.join(this.baseOutput, 'latest.json'), { + runId: this.runId, + runDirectory: this.relative(this.runDirectory), + graphFingerprint: analysis.graph.fingerprint, + summary: files.summary, + summaryConclusions: files.summaryConclusions, + }); + return this.pipelineResult(paths, manifest); + } + + private async writeIntentFiles(files: Record): Promise { + for (const [source, records] of Object.entries(this.bySource)) { + const filePath = path.join(this.runDirectory, `${source}.intent.jsonl`); + await writeJsonl(filePath, records); + files[`${source}Intent`] = this.relative(filePath); + } + } + + private outputPaths( + synthesis: SynthesisResult, + communicationAnalysis: CommunicationAnalysisResult | null, + ): OutputPaths { + const run = this.runDirectory; + return { + graphPath: path.join(run, 'intent.graph.json'), + diagnosticsPath: path.join(run, 'diagnostics.json'), + summaryPath: path.join(run, 'team-summary.md'), + summaryConclusionsPath: path.join(run, 'summary-conclusions.json'), + taskSynthesisPath: synthesis.taskSynthesis ? path.join(run, 'task-synthesis.json') : null, + todoValidationPath: synthesis.taskSynthesis ? path.join(run, 'todo-validation.json') : null, + todoPatchPath: synthesis.todoPatch ? path.join(run, 'TODO.patch') : null, + todoPatchAuditPath: synthesis.todoPatch ? path.join(run, 'TODO.patch.json') : null, + codeChangePlansPath: path.join(run, 'code-change-plans.json'), + codeChangeReviewPath: path.join(run, 'CODE_CHANGE.review.md'), + codeChangeReviewAuditPath: path.join(run, 'CODE_CHANGE.review.json'), + codeChangeSourcePatchesPath: path.join(run, 'code-change-source-patches.json'), + communicationAnalysisPath: communicationAnalysis ? path.join(run, 'communication-analysis.json') : null, + communicationMarkdownPath: communicationAnalysis ? path.join(run, 'communication-analysis.md') : null, + eventLogPath: path.join(run, 'logs.dsl.txt'), + }; + } + + private async writeRequiredOutputs( + paths: OutputPaths, + analysis: AnalysisResult, + planning: PlanningResult, + summary: PipelineSummaryResult, + files: Record, + ): Promise { + await writeJson(paths.graphPath, analysis.graph); + await writeJson(paths.diagnosticsPath, analysis.diagnostics); + await writeText(paths.summaryPath, summary.summary.markdown); + await writeJson(paths.summaryConclusionsPath, summary.summary.conclusions); + await writeJson(paths.codeChangePlansPath, planning.plans); + await writeText(paths.codeChangeReviewPath, planning.review.markdown); + await writeJson(paths.codeChangeReviewAuditPath, planning.review.artifact); + await writeJson(paths.codeChangeSourcePatchesPath, planning.sourcePatches); + files.codeChangePlans = this.relative(paths.codeChangePlansPath); + files.codeChangeReview = this.relative(paths.codeChangeReviewPath); + files.codeChangeReviewAudit = this.relative(paths.codeChangeReviewAuditPath); + files.codeChangeSourcePatches = this.relative(paths.codeChangeSourcePatchesPath); + files.graph = this.relative(paths.graphPath); + files.diagnostics = this.relative(paths.diagnosticsPath); + files.summary = this.relative(paths.summaryPath); + files.summaryConclusions = this.relative(paths.summaryConclusionsPath); + files.eventLog = this.relative(paths.eventLogPath); + } + + private async writeOptionalOutputs( + paths: OutputPaths, + communicationAnalysis: CommunicationAnalysisResult | null, + synthesis: SynthesisResult, + files: Record, + ): Promise { + if (paths.communicationAnalysisPath && paths.communicationMarkdownPath && communicationAnalysis) { + await Promise.all([ + writeJson(paths.communicationAnalysisPath, communicationAnalysis), + writeText(paths.communicationMarkdownPath, renderCommunicationMarkdown(communicationAnalysis)), + ]); + files.communicationAnalysis = this.relative(paths.communicationAnalysisPath); + files.communicationAnalysisMarkdown = this.relative(paths.communicationMarkdownPath); + } + if (paths.taskSynthesisPath && paths.todoValidationPath && paths.todoPatchPath + && paths.todoPatchAuditPath && synthesis.taskSynthesis && synthesis.todoPatch) { + await Promise.all([ + writeJson(paths.taskSynthesisPath, synthesis.taskSynthesis), + writeJson(paths.todoValidationPath, synthesis.taskSynthesis.validation), + writeText(paths.todoPatchPath, synthesis.todoPatch.markdown), + writeJson(paths.todoPatchAuditPath, synthesis.todoPatch.artifact), + ]); + files.taskSynthesis = this.relative(paths.taskSynthesisPath); + files.todoValidation = this.relative(paths.todoValidationPath); + files.todoPatch = this.relative(paths.todoPatchPath); + files.todoPatchAudit = this.relative(paths.todoPatchAuditPath); + } + } + + private createManifest( + files: Record, + extraction: ExtractionResult, + analysis: AnalysisResult, + synthesis: SynthesisResult, + planning: PlanningResult, + summary: PipelineSummaryResult, + ): PipelineManifest { + const stages = { + naturalLanguageExtraction: extraction.naturalLanguageAudit, + markdownExtraction: extraction.markdownAudit, + documentationExtraction: extraction.documentationAudit, + communicationAnalysis: extraction.communicationAudit, + taskSynthesis: synthesis.audit, + codeChangePlanning: planning.audit, + summary: summary.audit, + }; + return { + schemaVersion: 't2c.run/v1', runId: this.runId, root: this.root, + createdAt: analysis.generatedAt, graphFingerprint: analysis.graph.fingerprint, + files, warnings: [...new Set(this.warnings)].sort(), + status: Object.values(stages).some((stage) => stage.degraded) ? 'degraded' : 'succeeded', + failure: null, runtime: { name: 'todo2code', version: T2C_VERSION }, + configuration: manifestConfiguration(this.options, this.config), stages, + llm: { + naturalLanguageExtraction: extraction.naturalLanguageAudit.effectiveMode === 'llm', + markdownExtraction: extraction.markdownAudit.effectiveMode === 'llm', + documentationExtraction: extraction.documentationAudit.effectiveMode === 'llm', + communicationEnrichment: extraction.communicationAudit.effectiveMode === 'llm', + taskSynthesis: synthesis.audit.effectiveMode === 'llm', + summary: summary.summary.llmUsed, + }, + }; + } + + private pipelineResult(paths: OutputPaths, manifest: PipelineManifest): PipelineResult { + return { + runDirectory: this.runDirectory, manifest, + graphPath: paths.graphPath, diagnosticsPath: paths.diagnosticsPath, + summaryPath: paths.summaryPath, summaryConclusionsPath: paths.summaryConclusionsPath, + taskSynthesisPath: paths.taskSynthesisPath, todoPatchPath: paths.todoPatchPath, + todoPatchAuditPath: paths.todoPatchAuditPath, codeChangePlansPath: paths.codeChangePlansPath, + codeChangeReviewPath: paths.codeChangeReviewPath, + codeChangeReviewAuditPath: paths.codeChangeReviewAuditPath, + codeChangeSourcePatchesPath: paths.codeChangeSourcePatchesPath, + communicationAnalysisPath: paths.communicationAnalysisPath, + }; + } + + private relative(filePath: string): string { + return path.relative(this.root, filePath).replace(/\\/g, '/'); + } +} + +export async function runPipeline(options: PipelineOptions, config: T2CConfig): Promise { + return new PipelineRun(options, config).run(); +} diff --git a/src/pipeline/run-helpers.ts b/src/pipeline/run-helpers.ts new file mode 100644 index 00000000..4a0d9c61 --- /dev/null +++ b/src/pipeline/run-helpers.ts @@ -0,0 +1,196 @@ +import path from 'node:path'; +import type { T2CConfig } from '../config/env.js'; +import { hasOpenRouter } from '../config/env.js'; +import { CommunicationLlmRequiredError } from '../communication/llm.js'; +import { createIntentId, sha256, stableStringify } from '../core/id.js'; +import { writeJson } from '../core/io.js'; +import type { + Diagnostic, + DiagnosticReport, + IntentRecord, + PipelineFailureStage, + PipelineManifest, + PipelineOptions, + PipelineStageAudit, +} from '../core/types.js'; +import { DocumentationLlmRequiredError } from '../extractors/docs-llm.js'; +import { MarkdownLlmRequiredError } from '../extractors/markdown-llm.js'; +import { NlLlmRequiredError } from '../extractors/nl-llm.js'; +import { openRouterAuditConfiguration } from '../llm/audit.js'; +import { TaskSynthesisRequiredError } from '../synthesis/tasks-llm.js'; +import { T2C_VERSION } from '../version.js'; +import { persistPipelineEventLog } from './event-log-persistence.js'; + +export function manifestConfiguration(options: PipelineOptions, config: T2CConfig): PipelineManifest['configuration'] { + const configuration = { + nlMode: options.nlMode ?? config.nlMode, + markdownMode: options.markdownMode ?? config.markdownMode, + communicationMode: options.communicationMode ?? config.communicationMode, + gitCommitCount: options.gitCommitCount, + maxFileBytes: config.maxFileBytes, + markdownConcurrency: config.markdownConcurrency, + documentConcurrency: config.documentConcurrency, + documentChunkChars: config.documentChunkChars, + documentMaxChunks: config.documentMaxChunks, + documentRecordsPerChunk: config.documentRecordsPerChunk, + documentTimeoutMs: config.documentTimeoutMs, + summaryLlm: options.includeSummaryLlm !== false, + taskSynthesisMode: options.taskSynthesisMode ?? 'disabled', + includeCommunication: options.includeCommunication !== false, + projectDirectory: options.projectDirectory ?? 'project', + communicationTicket: options.communicationTicket ?? null, + documentPatterns: [...options.documentPatterns], + documentExcludes: [...(options.documentExcludes ?? config.documentExcludes)], + adapters: { + python: { enabled: config.enablePythonAst, executable: config.pythonExecutable }, + go: { enabled: config.enableGoAst, executable: config.goExecutable }, + java: { enabled: config.enableJavaAst, executable: config.javaExecutable }, + rust: { enabled: config.enableRustAst, executable: config.cargoExecutable }, + php: { enabled: config.enablePhpAst, executable: config.phpExecutable }, + tensorflow: { + enabled: config.enableTensorFlow, + modelPath: config.tensorflowModelPath, + modulePath: config.tensorflowModulePath, + labels: [...config.tensorflowLabels], + }, + }, + llm: { + configured: hasOpenRouter(config), + baseUrl: config.openRouter.baseUrl, + nlModel: config.openRouter.nlModel, + markdownModel: config.openRouter.markdownModel, + communicationModel: config.openRouter.communicationModel, + documentModel: config.openRouter.documentModel, + summaryModel: config.openRouter.summaryModel, + taskModel: config.openRouter.taskModel, + timeoutMs: config.openRouter.timeoutMs, + maxTokens: config.openRouter.maxTokens, + temperature: config.openRouter.temperature, + requireStructuredOutput: config.openRouter.requireStructuredOutput, + responseHealing: config.openRouter.responseHealing, + }, + }; + return { fingerprint: sha256(stableStringify(configuration)), ...configuration }; +} + +export function collectTargetHints(records: IntentRecord[]): { paths: string[]; symbols: string[]; tickets: string[]; versions: string[] } { + const values = (key: K): string[] => [ + ...new Set(records.flatMap((record) => record.statement.target[key])), + ].slice(0, 200); + return { + paths: values('paths'), + symbols: values('symbols'), + tickets: values('tickets'), + versions: values('versions'), + }; +} + +export async function persistFailedRun( + runId: string, + root: string, + runDirectory: string, + options: PipelineOptions, + config: T2CConfig, + error: unknown, + failedStage: PipelineFailureStage, + completedStages: Partial, +): Promise { + const aborted = (stage: string): PipelineStageAudit => ({ + ...skippedAudit('disabled', `Pipeline aborted before ${stage}`), + reason: { code: 'PIPELINE_ABORTED', message: `Pipeline aborted before ${stage}` }, + }); + const message = error instanceof Error ? error.message : String(error); + const knownAudit = error instanceof NlLlmRequiredError + || error instanceof MarkdownLlmRequiredError + || error instanceof DocumentationLlmRequiredError + || error instanceof CommunicationLlmRequiredError + || error instanceof TaskSynthesisRequiredError + ? error.audit + : null; + const failedAudit = (stage: keyof PipelineManifest['stages']): PipelineStageAudit => { + if (knownAudit && stage === failedStage) return knownAudit; + return { + runtimeVersion: T2C_VERSION, + configuration: openRouterAuditConfiguration( + config, + stage === 'summary' ? config.openRouter.summaryModel : stage === 'taskSynthesis' ? config.openRouter.taskModel : null, + ), + status: 'failed', requestedMode: stage === 'summary' || stage === 'taskSynthesis' ? 'llm' : 'disabled', effectiveMode: 'none', degraded: true, + recordCount: 0, warningCount: 1, + model: stage === 'summary' ? config.openRouter.summaryModel : stage === 'taskSynthesis' ? config.openRouter.taskModel : null, + durationMs: 0, + reason: { code: failureCode(failedStage), message }, + responses: [], + }; + }; + const stageValue = (stage: keyof PipelineManifest['stages'], label: string): PipelineStageAudit => { + if (completedStages[stage]) return completedStages[stage]; + if (stage === failedStage) return failedAudit(stage); + return aborted(label); + }; + const stages: PipelineManifest['stages'] = { + naturalLanguageExtraction: stageValue('naturalLanguageExtraction', 'natural-language extraction'), + markdownExtraction: stageValue('markdownExtraction', 'Markdown extraction'), + documentationExtraction: stageValue('documentationExtraction', 'documentation extraction'), + communicationAnalysis: stageValue('communicationAnalysis', 'communication analysis'), + taskSynthesis: stageValue('taskSynthesis', 'task synthesis'), + codeChangePlanning: stageValue('codeChangePlanning', 'code-change planning'), + summary: stageValue('summary', 'summary generation'), + }; + const reason = knownAudit?.reason ?? { code: failureCode(failedStage), message }; + const manifest: PipelineManifest = { + schemaVersion: 't2c.run/v1', + runId, + root, + createdAt: new Date().toISOString(), + graphFingerprint: null, + files: { + eventLog: path.relative(root, path.join(runDirectory, 'logs.dsl.txt')).replace(/\\/g, '/'), + }, + warnings: [message], + status: 'failed', + failure: { stage: failedStage, code: reason.code, message: reason.message }, + runtime: { name: 'todo2code', version: T2C_VERSION }, + configuration: manifestConfiguration(options, config), + stages, + llm: { + naturalLanguageExtraction: stages.naturalLanguageExtraction.effectiveMode === 'llm', + markdownExtraction: stages.markdownExtraction.effectiveMode === 'llm', + communicationEnrichment: stages.communicationAnalysis.effectiveMode === 'llm', + documentationExtraction: false, + taskSynthesis: false, + summary: false, + }, + }; + await writeJson(path.join(runDirectory, 'manifest.json'), manifest); + await persistPipelineEventLog({ root, runDirectory, manifest, replaceUnfinished: true }); +} + +export function failureCode(stage: PipelineFailureStage): string { + return `PIPELINE_${stage.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase()}_FAILED`; +} + +export function skippedAudit(requestedMode: PipelineStageAudit['requestedMode'], message: string): PipelineStageAudit { + return { + runtimeVersion: T2C_VERSION, + configuration: {}, + status: 'skipped', requestedMode, effectiveMode: 'none', degraded: false, + recordCount: 0, warningCount: 0, model: null, durationMs: 0, + reason: { code: 'STAGE_SKIPPED', message }, + responses: [], + }; +} + +export function appendLlmNotConfigured(report: DiagnosticReport): void { + const diagnostic: Diagnostic = { + id: createIntentId({ code: 'LLM_NOT_CONFIGURED', graph: report.graphFingerprint }, 'DIAG'), + code: 'LLM_NOT_CONFIGURED', + severity: 'warning', + title: 'OpenRouter nie jest skonfigurowany', + detail: 'Etap dokumentacja -> Intent DSL został pominięty, ponieważ brakuje OPENROUTER_API_KEY.', + recordIds: [], + suggestedAction: 'Ustawić OPENROUTER_API_KEY w .env i ponownie uruchomić pipeline.', + }; + report.diagnostics.unshift(diagnostic); + report.counts.warning += 1; +} diff --git a/src/pipeline/run-types.ts b/src/pipeline/run-types.ts new file mode 100644 index 00000000..15df53ca --- /dev/null +++ b/src/pipeline/run-types.ts @@ -0,0 +1,92 @@ +import type { analyzeCommunication } from '../communication/analyzer.js'; +import type { ParticipantCommunicationSynthesis } from '../communication/llm.js'; +import type { + DiagnosticReport, + PipelineManifest, + PipelineStageAudit, +} from '../core/types.js'; +import type { linkIntentRecords } from '../graph/linker.js'; +import type { summarizeGraph } from '../summary/summarizer.js'; +import type { + createCodeChangeReviewPatch, + createCodeChangeSourcePatchSet, + proposeCodeChangePlans, +} from '../synthesis/code-change-plan.js'; +import type { AuditedTaskSynthesisResult } from '../synthesis/tasks-llm.js'; +import type { CreatedTodoPatch } from '../synthesis/todo-patch.js'; + +export interface PipelineResult { + runDirectory: string; + manifest: PipelineManifest; + graphPath: string; + diagnosticsPath: string; + summaryPath: string; + summaryConclusionsPath: string; + taskSynthesisPath: string | null; + todoPatchPath: string | null; + todoPatchAuditPath: string | null; + codeChangePlansPath: string | null; + codeChangeReviewPath: string | null; + codeChangeReviewAuditPath: string | null; + codeChangeSourcePatchesPath: string | null; + communicationAnalysisPath: string | null; +} + +export type IntentGraphResult = ReturnType; +export type CommunicationAnalysisResult = ReturnType; +export type CodeChangePlansResult = ReturnType; +export type CodeChangeReviewResult = ReturnType; +export type CodeChangeSourcePatchesResult = ReturnType; +export type SummaryResult = Awaited>; + +export interface ExtractionResult { + naturalLanguageAudit: PipelineStageAudit; + markdownAudit: PipelineStageAudit; + documentationAudit: PipelineStageAudit; + communicationAudit: PipelineStageAudit; + communicationInputPresent: boolean; + communicationSyntheses: ParticipantCommunicationSynthesis[]; +} + +export interface AnalysisResult { + generatedAt: string; + graph: IntentGraphResult; + diagnostics: DiagnosticReport; + communicationAnalysis: CommunicationAnalysisResult | null; +} + +export interface SynthesisResult { + taskSynthesis: AuditedTaskSynthesisResult | null; + todoPatch: CreatedTodoPatch | null; + audit: PipelineStageAudit; +} + +export interface PlanningResult { + plans: CodeChangePlansResult; + review: CodeChangeReviewResult; + sourcePatches: CodeChangeSourcePatchesResult; + audit: PipelineStageAudit; +} + +export interface PipelineSummaryResult { + summary: SummaryResult; + audit: PipelineStageAudit; +} + +export interface OutputPaths { + graphPath: string; + diagnosticsPath: string; + summaryPath: string; + summaryConclusionsPath: string; + taskSynthesisPath: string | null; + todoValidationPath: string | null; + todoPatchPath: string | null; + todoPatchAuditPath: string | null; + codeChangePlansPath: string; + codeChangeReviewPath: string; + codeChangeReviewAuditPath: string; + codeChangeSourcePatchesPath: string; + communicationAnalysisPath: string | null; + communicationMarkdownPath: string | null; + eventLogPath: string; +} diff --git a/src/pipeline/run.ts b/src/pipeline/run.ts index c9c4259c..810e3f36 100644 --- a/src/pipeline/run.ts +++ b/src/pipeline/run.ts @@ -1,795 +1,2 @@ -import path from 'node:path'; -import type { T2CConfig } from '../config/env.js'; -import { hasOpenRouter } from '../config/env.js'; -import { addCommunicationIssuesToDiagnostics, analyzeCommunication, renderCommunicationMarkdown } from '../communication/analyzer.js'; -import { CommunicationLlmRequiredError, extractCommunicationIntentAudited, type ParticipantCommunicationSynthesis } from '../communication/llm.js'; -import { createIntentId, newRunId, sha256, stableStringify } from '../core/id.js'; -import { ensureDir, pathExists, readText, resolveGlobs, writeJson, writeJsonl, writeText } from '../core/io.js'; -import type { - Diagnostic, - DiagnosticReport, - IntentRecord, - PipelineManifest, - PipelineFailureStage, - PipelineOptions, - PipelineStageAudit, -} from '../core/types.js'; -import { extractAstIntent } from '../extractors/ast.js'; -import { extractConfigurationIntent } from '../extractors/configuration.js'; -import { extractRuntimeCycleIntent } from '../extractors/runtime-cycle.js'; -import { DocumentationLlmRequiredError, extractDocumentationIntent } from '../extractors/docs-llm.js'; -import { extractDocumentationBaseline } from '../extractors/docs-deterministic.js'; -import { extractGitIntent } from '../extractors/git.js'; -import { extractMarkdownIntentAudited, MarkdownLlmRequiredError } from '../extractors/markdown-llm.js'; -import { extractNlIntentAudited, NlLlmRequiredError } from '../extractors/nl-llm.js'; -import { openRouterAuditConfiguration } from '../llm/audit.js'; -import { diagnoseGraph } from '../graph/diagnostics.js'; -import { linkIntentRecords } from '../graph/linker.js'; -import { summarizeGraph } from '../summary/summarizer.js'; -import { - createCodeChangeReviewPatch, - createCodeChangeSourcePatchSet, - createRepositoryPathProbe, - proposeCodeChangePlans, -} from '../synthesis/code-change-plan.js'; -import { synthesizeTodoProposals, TaskSynthesisRequiredError, type AuditedTaskSynthesisResult } from '../synthesis/tasks-llm.js'; -import { createTodoPatch, type CreatedTodoPatch } from '../synthesis/todo-patch.js'; -import { T2C_VERSION } from '../version.js'; -import { persistPipelineEventLog } from './event-log-persistence.js'; - -export interface PipelineResult { - runDirectory: string; - manifest: PipelineManifest; - graphPath: string; - diagnosticsPath: string; - summaryPath: string; - summaryConclusionsPath: string; - taskSynthesisPath: string | null; - todoPatchPath: string | null; - todoPatchAuditPath: string | null; - codeChangePlansPath: string | null; - codeChangeReviewPath: string | null; - codeChangeReviewAuditPath: string | null; - codeChangeSourcePatchesPath: string | null; - communicationAnalysisPath: string | null; -} - -export async function runPipeline(options: PipelineOptions, config: T2CConfig): Promise { - return new PipelineRun(options, config).run(); -} - -type IntentGraphResult = ReturnType; -type CommunicationAnalysisResult = ReturnType; -type CodeChangePlansResult = ReturnType; -type CodeChangeReviewResult = ReturnType; -type CodeChangeSourcePatchesResult = ReturnType; -type SummaryResult = Awaited>; - -interface ExtractionResult { - naturalLanguageAudit: PipelineStageAudit; - markdownAudit: PipelineStageAudit; - documentationAudit: PipelineStageAudit; - communicationAudit: PipelineStageAudit; - communicationInputPresent: boolean; - communicationSyntheses: ParticipantCommunicationSynthesis[]; -} - -interface AnalysisResult { - generatedAt: string; - graph: IntentGraphResult; - diagnostics: DiagnosticReport; - communicationAnalysis: CommunicationAnalysisResult | null; -} - -interface SynthesisResult { - taskSynthesis: AuditedTaskSynthesisResult | null; - todoPatch: CreatedTodoPatch | null; - audit: PipelineStageAudit; -} - -interface PlanningResult { - plans: CodeChangePlansResult; - review: CodeChangeReviewResult; - sourcePatches: CodeChangeSourcePatchesResult; - audit: PipelineStageAudit; -} - -interface PipelineSummaryResult { - summary: SummaryResult; - audit: PipelineStageAudit; -} - -interface OutputPaths { - graphPath: string; - diagnosticsPath: string; - summaryPath: string; - summaryConclusionsPath: string; - taskSynthesisPath: string | null; - todoValidationPath: string | null; - todoPatchPath: string | null; - todoPatchAuditPath: string | null; - codeChangePlansPath: string; - codeChangeReviewPath: string; - codeChangeReviewAuditPath: string; - codeChangeSourcePatchesPath: string; - communicationAnalysisPath: string | null; - communicationMarkdownPath: string | null; - eventLogPath: string; -} - -class PipelineRun { - private readonly root: string; - private readonly runId = newRunId(); - private readonly baseOutput: string; - private readonly runDirectory: string; - private activeStage: PipelineFailureStage = 'setup'; - private readonly completedStages: Partial = {}; - private readonly warnings: string[] = []; - private readonly bySource: Record = { - nl: [], git: [], ast: [], todo: [], changelog: [], document: [], - configuration: [], runtime: [], communication: [], - }; - - constructor(private readonly options: PipelineOptions, private readonly config: T2CConfig) { - this.root = path.resolve(options.root); - this.baseOutput = path.resolve(this.root, options.outputDir); - this.runDirectory = path.join(this.baseOutput, 'runs', this.runId); - } - - async run(): Promise { - if (!(await pathExists(this.root))) throw new Error(`Root does not exist: ${this.root}`); - await ensureDir(this.runDirectory); - try { - return await this.execute(); - } catch (error) { - await persistFailedRun( - this.runId, this.root, this.runDirectory, this.options, this.config, - error, this.activeStage, this.completedStages, - ); - throw error; - } - } - - private async execute(): Promise { - const extraction = await this.extractSources(); - const analysis = this.analyze(extraction); - const synthesis = await this.synthesizeTasks(analysis); - const planning = this.planChanges(analysis, synthesis); - const summary = await this.summarize(analysis); - return this.persist(extraction, analysis, synthesis, planning, summary); - } - - private async extractSources(): Promise { - const naturalLanguageAudit = await this.extractNaturalLanguage(); - this.activeStage = 'gitExtraction'; - const git = await extractGitIntent({ root: this.root, count: this.options.gitCommitCount }, this.config); - this.bySource.git = git.records; - this.warnings.push(...git.warnings); - this.activeStage = 'astExtraction'; - const ast = await extractAstIntent({ root: this.root }, this.config); - this.bySource.ast = ast.records; - this.warnings.push(...ast.warnings); - const markdownAudit = await this.extractMarkdown(); - const documentationAudit = await this.extractDocumentation(); - await this.extractConfigurationAndRuntime(); - const communication = await this.extractCommunication(); - return { naturalLanguageAudit, markdownAudit, documentationAudit, ...communication }; - } - - private async extractNaturalLanguage(): Promise { - let audit = skippedAudit('disabled', 'No NL task file was selected'); - if (this.options.taskFile) { - this.activeStage = 'naturalLanguageExtraction'; - const result = await extractNlIntentAudited( - { root: this.root, sourcePath: this.options.taskFile }, - this.config, - this.options.nlMode ?? this.config.nlMode, - ); - this.bySource.nl = result.records; - this.warnings.push(...result.warnings); - audit = result.audit; - } - this.completedStages.naturalLanguageExtraction = audit; - return audit; - } - - private async extractMarkdown(): Promise { - this.activeStage = 'markdownExtraction'; - const markdown = await extractMarkdownIntentAudited( - { root: this.root, todoPath: this.options.todoFile, changelogPath: this.options.changelogFile }, - this.config, - this.options.markdownMode ?? this.config.markdownMode, - ); - this.bySource.todo = markdown.records.filter((record) => record.source.kind === 'todo'); - this.bySource.changelog = markdown.records.filter((record) => record.source.kind === 'changelog'); - this.warnings.push(...markdown.warnings); - this.completedStages.markdownExtraction = markdown.audit; - return markdown.audit; - } - - private async extractDocumentation(): Promise { - this.activeStage = 'documentationExtraction'; - const files = await resolveGlobs( - this.root, this.options.documentPatterns, - this.options.documentExcludes ?? this.config.documentExcludes, - ); - const startedAt = Date.now(); - const deterministic = await extractDocumentationBaseline({ root: this.root, files }, this.config); - this.bySource.document = deterministic.records; - this.warnings.push(...deterministic.warnings); - let audit = this.deterministicDocumentationAudit(files.length, deterministic, startedAt); - if (this.options.includeDocumentationLlm) { - audit = hasOpenRouter(this.config) - ? await this.enrichDocumentation(deterministic.records.length) - : this.missingDocumentationLlmAudit(deterministic.records.length); - } - this.completedStages.documentationExtraction = audit; - return audit; - } - - private deterministicDocumentationAudit( - fileCount: number, - result: Awaited>, - startedAt: number, - ): PipelineStageAudit { - if (fileCount === 0) return skippedAudit('deterministic', 'No documentation files matched the configured patterns'); - return { - runtimeVersion: T2C_VERSION, - configuration: { generator: 't2c/markdown-documentation', generatorVersion: '2' }, - status: result.warnings.length ? 'partial' : 'succeeded', - requestedMode: 'deterministic', effectiveMode: 'deterministic', - degraded: result.warnings.length > 0, - recordCount: result.records.length, warningCount: result.warnings.length, - model: null, durationMs: Date.now() - startedAt, - reason: result.warnings.length - ? { code: 'DOCUMENT_EXTRACTION_PARTIAL', message: `${result.warnings.length} deterministic documentation warning(s)` } - : null, - responses: [], - }; - } - - private async enrichDocumentation(deterministicRecordCount: number): Promise { - this.activeStage = 'documentationExtraction'; - const docs = await extractDocumentationIntent({ - root: this.root, - patterns: this.options.documentPatterns, - excludes: this.options.documentExcludes ?? this.config.documentExcludes, - targetHints: collectTargetHints(Object.values(this.bySource).flat()), - }, this.config); - this.bySource.document!.push(...docs.records); - this.warnings.push(...docs.warnings); - return { - ...docs.audit, - recordCount: this.bySource.document!.length, - configuration: { - ...docs.audit.configuration, - deterministicGenerator: 't2c/markdown-documentation@2', - deterministicRecordCount, - }, - }; - } - - private missingDocumentationLlmAudit(deterministicRecordCount: number): PipelineStageAudit { - const message = 'OPENROUTER_API_KEY is not configured; documentation -> Intent DSL was skipped'; - this.warnings.push(message); - return { - ...skippedAudit('llm', message), - configuration: openRouterAuditConfiguration( - this.config, this.config.openRouter.documentModel, this.config.documentTimeoutMs, - ), - status: deterministicRecordCount ? 'fallback' : 'failed', - effectiveMode: deterministicRecordCount ? 'deterministic' : 'none', - degraded: true, recordCount: deterministicRecordCount, - model: this.config.openRouter.documentModel, - reason: { code: 'LLM_NOT_CONFIGURED', message }, responses: [], - }; - } - - private async extractConfigurationAndRuntime(): Promise { - this.activeStage = 'configurationExtraction'; - const configuration = await extractConfigurationIntent(this.root, this.config); - this.bySource.configuration = configuration.records; - this.warnings.push(...configuration.warnings); - this.activeStage = 'runtimeExtraction'; - if (!this.options.cycleFile) return; - try { - const runtime = await extractRuntimeCycleIntent(this.options.cycleFile, this.config, this.root); - this.bySource.runtime = runtime.records; - this.warnings.push(...runtime.warnings); - } catch (error) { - this.warnings.push(`runtime cycle ignored: ${error instanceof Error ? error.message : String(error)}`); - } - } - - private async extractCommunication(): Promise> { - let communicationAudit = skippedAudit('disabled', 'Communication analysis was disabled'); - if (this.options.includeCommunication === false) { - this.completedStages.communicationAnalysis = communicationAudit; - return { communicationAudit, communicationInputPresent: false, communicationSyntheses: [] }; - } - this.activeStage = 'communicationAnalysis'; - const startedAt = Date.now(); - const communication = await extractCommunicationIntentAudited({ - root: this.root, - projectDir: this.options.projectDirectory ?? 'project', - ticket: this.options.communicationTicket ?? null, - }, this.config, this.options.communicationMode ?? this.config.communicationMode); - const missing = communication.records.length === 0 && communication.warnings.length === 1 - && communication.warnings[0]?.startsWith('Communication directory not found:'); - this.bySource.communication = communication.records; - if (!missing) this.warnings.push(...communication.warnings); - communicationAudit = missing - ? skippedAudit('deterministic', communication.warnings[0] ?? 'Communication directory not found') - : { ...communication.audit, durationMs: Date.now() - startedAt }; - this.completedStages.communicationAnalysis = communicationAudit; - return { - communicationAudit, - communicationInputPresent: !missing, - communicationSyntheses: communication.participants, - }; - } - - private analyze(extraction: ExtractionResult): AnalysisResult { - this.activeStage = 'linking'; - const generatedAt = new Date().toISOString(); - const graph = linkIntentRecords(Object.values(this.bySource).flat(), generatedAt); - this.activeStage = 'diagnostics'; - const communicationAnalysis = extraction.communicationInputPresent - ? analyzeCommunication(graph, generatedAt, extraction.communicationSyntheses) - : null; - let diagnostics = diagnoseGraph(graph, generatedAt); - if (communicationAnalysis) diagnostics = addCommunicationIssuesToDiagnostics(diagnostics, communicationAnalysis); - if (this.options.includeDocumentationLlm && !hasOpenRouter(this.config)) appendLlmNotConfigured(diagnostics); - return { generatedAt, graph, diagnostics, communicationAnalysis }; - } - - private async synthesizeTasks(analysis: AnalysisResult): Promise { - const mode = this.options.taskSynthesisMode ?? 'disabled'; - let taskSynthesis: AuditedTaskSynthesisResult | null = null; - let todoPatch: CreatedTodoPatch | null = null; - let audit = skippedAudit('disabled', 'Task synthesis was disabled'); - if (mode !== 'disabled') { - this.activeStage = 'taskSynthesis'; - taskSynthesis = await synthesizeTodoProposals(analysis.graph, analysis.diagnostics, this.config, mode); - this.warnings.push(...taskSynthesis.warnings); - audit = taskSynthesis.audit; - this.completedStages.taskSynthesis = audit; - if (!this.options.todoFile) throw new Error('Task synthesis rendering requires a TODO source file'); - this.activeStage = 'todoRendering'; - const todoPath = path.resolve(this.root, this.options.todoFile); - const todoContent = await readText(todoPath, this.config.maxFileBytes); - todoPatch = createTodoPatch({ - todoPath: path.relative(this.root, todoPath).replace(/\\/g, '/'), todoContent, - graph: analysis.graph, diagnostics: analysis.diagnostics, - conclusions: taskSynthesis.conclusions, proposals: taskSynthesis.proposals, - validation: taskSynthesis.validation, synthesisAudit: taskSynthesis.audit, - }); - } - this.completedStages.taskSynthesis = audit; - return { taskSynthesis, todoPatch, audit }; - } - - private planChanges(analysis: AnalysisResult, synthesis: SynthesisResult): PlanningResult { - this.activeStage = 'codeChangePlanning'; - const plans = proposeCodeChangePlans({ - graph: analysis.graph, diagnostics: analysis.diagnostics, - ...(synthesis.taskSynthesis ? { - conclusions: synthesis.taskSynthesis.conclusions, - proposals: synthesis.taskSynthesis.proposals, - } : {}), - generatedAt: analysis.generatedAt, - pathExists: createRepositoryPathProbe(this.root), - }); - const review = createCodeChangeReviewPatch({ - plans: plans.plans, graphFingerprint: analysis.graph.fingerprint, - createdAt: analysis.generatedAt, - }); - const sourcePatches = createCodeChangeSourcePatchSet({ - plans: plans.plans, graphFingerprint: analysis.graph.fingerprint, - generatedAt: analysis.generatedAt, - }); - const audit: PipelineStageAudit = { - runtimeVersion: T2C_VERSION, configuration: openRouterAuditConfiguration(this.config, null), - status: 'succeeded', requestedMode: 'deterministic', effectiveMode: 'deterministic', - degraded: false, recordCount: plans.plans.length, warningCount: 0, - model: null, durationMs: 0, reason: null, responses: [], - }; - this.completedStages.codeChangePlanning = audit; - return { plans, review, sourcePatches, audit }; - } - - private async summarize(analysis: AnalysisResult): Promise { - this.activeStage = 'summary'; - const startedAt = Date.now(); - const includeLlm = this.options.includeSummaryLlm !== false; - const summary = await summarizeGraph(analysis.graph, analysis.diagnostics, this.config, { - allowDeterministicFallback: this.options.allowSummaryFallback, - preferLlm: includeLlm, - }); - this.warnings.push(...summary.warnings); - let audit: PipelineStageAudit; - if (!includeLlm) audit = this.disabledSummaryAudit(summary, startedAt); - else if (summary.llmUsed) audit = this.successfulSummaryAudit(summary, startedAt); - else audit = this.fallbackSummaryAudit(summary, startedAt); - this.completedStages.summary = audit; - return { summary, audit }; - } - - private disabledSummaryAudit(summary: SummaryResult, startedAt: number): PipelineStageAudit { - return { - runtimeVersion: T2C_VERSION, configuration: openRouterAuditConfiguration(this.config, null), - status: 'skipped', requestedMode: 'deterministic', effectiveMode: 'deterministic', degraded: false, - recordCount: summary.conclusions.length, warningCount: 0, model: null, - durationMs: Date.now() - startedAt, - reason: { code: 'LLM_DISABLED', message: 'LLM summary was disabled; generated the deterministic report' }, - responses: [], - }; - } - - private successfulSummaryAudit(summary: SummaryResult, startedAt: number): PipelineStageAudit { - return { - runtimeVersion: T2C_VERSION, - configuration: openRouterAuditConfiguration(this.config, this.config.openRouter.summaryModel), - status: 'succeeded', requestedMode: 'llm', effectiveMode: 'llm', degraded: false, - recordCount: summary.conclusions.length, warningCount: summary.warnings.length, - model: this.config.openRouter.summaryModel, durationMs: Date.now() - startedAt, - reason: null, responses: summary.responses, - }; - } - - private fallbackSummaryAudit(summary: SummaryResult, startedAt: number): PipelineStageAudit { - return { - runtimeVersion: T2C_VERSION, - configuration: openRouterAuditConfiguration(this.config, this.config.openRouter.summaryModel), - status: 'fallback', requestedMode: 'llm', effectiveMode: 'deterministic', degraded: true, - recordCount: summary.conclusions.length, warningCount: summary.warnings.length, - model: this.config.openRouter.summaryModel, durationMs: Date.now() - startedAt, - reason: { - code: hasOpenRouter(this.config) ? 'LLM_UNAVAILABLE' : 'LLM_NOT_CONFIGURED', - message: summary.warnings[0] ?? 'Deterministic summary fallback was used', - }, - responses: [], - }; - } - - private async persist( - extraction: ExtractionResult, - analysis: AnalysisResult, - synthesis: SynthesisResult, - planning: PlanningResult, - summary: PipelineSummaryResult, - ): Promise { - this.activeStage = 'persistence'; - const files: Record = {}; - await this.writeIntentFiles(files); - const paths = this.outputPaths(synthesis, analysis.communicationAnalysis); - await this.writeRequiredOutputs(paths, analysis, planning, summary, files); - await this.writeOptionalOutputs(paths, analysis.communicationAnalysis, synthesis, files); - const manifest = this.createManifest(files, extraction, analysis, synthesis, planning, summary); - await writeJson(path.join(this.runDirectory, 'manifest.json'), manifest); - await persistPipelineEventLog({ root: this.root, runDirectory: this.runDirectory, manifest }); - await writeJson(path.join(this.baseOutput, 'latest.json'), { - runId: this.runId, - runDirectory: this.relative(this.runDirectory), - graphFingerprint: analysis.graph.fingerprint, - summary: files.summary, - summaryConclusions: files.summaryConclusions, - }); - return this.pipelineResult(paths, manifest); - } - - private async writeIntentFiles(files: Record): Promise { - for (const [source, records] of Object.entries(this.bySource)) { - const filePath = path.join(this.runDirectory, `${source}.intent.jsonl`); - await writeJsonl(filePath, records); - files[`${source}Intent`] = this.relative(filePath); - } - } - - private outputPaths( - synthesis: SynthesisResult, - communicationAnalysis: CommunicationAnalysisResult | null, - ): OutputPaths { - const run = this.runDirectory; - return { - graphPath: path.join(run, 'intent.graph.json'), - diagnosticsPath: path.join(run, 'diagnostics.json'), - summaryPath: path.join(run, 'team-summary.md'), - summaryConclusionsPath: path.join(run, 'summary-conclusions.json'), - taskSynthesisPath: synthesis.taskSynthesis ? path.join(run, 'task-synthesis.json') : null, - todoValidationPath: synthesis.taskSynthesis ? path.join(run, 'todo-validation.json') : null, - todoPatchPath: synthesis.todoPatch ? path.join(run, 'TODO.patch') : null, - todoPatchAuditPath: synthesis.todoPatch ? path.join(run, 'TODO.patch.json') : null, - codeChangePlansPath: path.join(run, 'code-change-plans.json'), - codeChangeReviewPath: path.join(run, 'CODE_CHANGE.review.md'), - codeChangeReviewAuditPath: path.join(run, 'CODE_CHANGE.review.json'), - codeChangeSourcePatchesPath: path.join(run, 'code-change-source-patches.json'), - communicationAnalysisPath: communicationAnalysis ? path.join(run, 'communication-analysis.json') : null, - communicationMarkdownPath: communicationAnalysis ? path.join(run, 'communication-analysis.md') : null, - eventLogPath: path.join(run, 'logs.dsl.txt'), - }; - } - - private async writeRequiredOutputs( - paths: OutputPaths, - analysis: AnalysisResult, - planning: PlanningResult, - summary: PipelineSummaryResult, - files: Record, - ): Promise { - await writeJson(paths.graphPath, analysis.graph); - await writeJson(paths.diagnosticsPath, analysis.diagnostics); - await writeText(paths.summaryPath, summary.summary.markdown); - await writeJson(paths.summaryConclusionsPath, summary.summary.conclusions); - await writeJson(paths.codeChangePlansPath, planning.plans); - await writeText(paths.codeChangeReviewPath, planning.review.markdown); - await writeJson(paths.codeChangeReviewAuditPath, planning.review.artifact); - await writeJson(paths.codeChangeSourcePatchesPath, planning.sourcePatches); - files.codeChangePlans = this.relative(paths.codeChangePlansPath); - files.codeChangeReview = this.relative(paths.codeChangeReviewPath); - files.codeChangeReviewAudit = this.relative(paths.codeChangeReviewAuditPath); - files.codeChangeSourcePatches = this.relative(paths.codeChangeSourcePatchesPath); - files.graph = this.relative(paths.graphPath); - files.diagnostics = this.relative(paths.diagnosticsPath); - files.summary = this.relative(paths.summaryPath); - files.summaryConclusions = this.relative(paths.summaryConclusionsPath); - files.eventLog = this.relative(paths.eventLogPath); - } - - private async writeOptionalOutputs( - paths: OutputPaths, - communicationAnalysis: CommunicationAnalysisResult | null, - synthesis: SynthesisResult, - files: Record, - ): Promise { - if (paths.communicationAnalysisPath && paths.communicationMarkdownPath && communicationAnalysis) { - await Promise.all([ - writeJson(paths.communicationAnalysisPath, communicationAnalysis), - writeText(paths.communicationMarkdownPath, renderCommunicationMarkdown(communicationAnalysis)), - ]); - files.communicationAnalysis = this.relative(paths.communicationAnalysisPath); - files.communicationAnalysisMarkdown = this.relative(paths.communicationMarkdownPath); - } - if (paths.taskSynthesisPath && paths.todoValidationPath && paths.todoPatchPath - && paths.todoPatchAuditPath && synthesis.taskSynthesis && synthesis.todoPatch) { - await Promise.all([ - writeJson(paths.taskSynthesisPath, synthesis.taskSynthesis), - writeJson(paths.todoValidationPath, synthesis.taskSynthesis.validation), - writeText(paths.todoPatchPath, synthesis.todoPatch.markdown), - writeJson(paths.todoPatchAuditPath, synthesis.todoPatch.artifact), - ]); - files.taskSynthesis = this.relative(paths.taskSynthesisPath); - files.todoValidation = this.relative(paths.todoValidationPath); - files.todoPatch = this.relative(paths.todoPatchPath); - files.todoPatchAudit = this.relative(paths.todoPatchAuditPath); - } - } - - private createManifest( - files: Record, - extraction: ExtractionResult, - analysis: AnalysisResult, - synthesis: SynthesisResult, - planning: PlanningResult, - summary: PipelineSummaryResult, - ): PipelineManifest { - const stages = { - naturalLanguageExtraction: extraction.naturalLanguageAudit, - markdownExtraction: extraction.markdownAudit, - documentationExtraction: extraction.documentationAudit, - communicationAnalysis: extraction.communicationAudit, - taskSynthesis: synthesis.audit, - codeChangePlanning: planning.audit, - summary: summary.audit, - }; - return { - schemaVersion: 't2c.run/v1', runId: this.runId, root: this.root, - createdAt: analysis.generatedAt, graphFingerprint: analysis.graph.fingerprint, - files, warnings: [...new Set(this.warnings)].sort(), - status: Object.values(stages).some((stage) => stage.degraded) ? 'degraded' : 'succeeded', - failure: null, runtime: { name: 'todo2code', version: T2C_VERSION }, - configuration: manifestConfiguration(this.options, this.config), stages, - llm: { - naturalLanguageExtraction: extraction.naturalLanguageAudit.effectiveMode === 'llm', - markdownExtraction: extraction.markdownAudit.effectiveMode === 'llm', - documentationExtraction: extraction.documentationAudit.effectiveMode === 'llm', - communicationEnrichment: extraction.communicationAudit.effectiveMode === 'llm', - taskSynthesis: synthesis.audit.effectiveMode === 'llm', - summary: summary.summary.llmUsed, - }, - }; - } - - private pipelineResult(paths: OutputPaths, manifest: PipelineManifest): PipelineResult { - return { - runDirectory: this.runDirectory, manifest, - graphPath: paths.graphPath, diagnosticsPath: paths.diagnosticsPath, - summaryPath: paths.summaryPath, summaryConclusionsPath: paths.summaryConclusionsPath, - taskSynthesisPath: paths.taskSynthesisPath, todoPatchPath: paths.todoPatchPath, - todoPatchAuditPath: paths.todoPatchAuditPath, codeChangePlansPath: paths.codeChangePlansPath, - codeChangeReviewPath: paths.codeChangeReviewPath, - codeChangeReviewAuditPath: paths.codeChangeReviewAuditPath, - codeChangeSourcePatchesPath: paths.codeChangeSourcePatchesPath, - communicationAnalysisPath: paths.communicationAnalysisPath, - }; - } - - private relative(filePath: string): string { - return path.relative(this.root, filePath).replace(/\\/g, '/'); - } -} - -function manifestConfiguration(options: PipelineOptions, config: T2CConfig): PipelineManifest['configuration'] { - const configuration = { - nlMode: options.nlMode ?? config.nlMode, - markdownMode: options.markdownMode ?? config.markdownMode, - communicationMode: options.communicationMode ?? config.communicationMode, - gitCommitCount: options.gitCommitCount, - maxFileBytes: config.maxFileBytes, - markdownConcurrency: config.markdownConcurrency, - documentConcurrency: config.documentConcurrency, - documentChunkChars: config.documentChunkChars, - documentMaxChunks: config.documentMaxChunks, - documentRecordsPerChunk: config.documentRecordsPerChunk, - documentTimeoutMs: config.documentTimeoutMs, - summaryLlm: options.includeSummaryLlm !== false, - taskSynthesisMode: options.taskSynthesisMode ?? 'disabled', - includeCommunication: options.includeCommunication !== false, - projectDirectory: options.projectDirectory ?? 'project', - communicationTicket: options.communicationTicket ?? null, - documentPatterns: [...options.documentPatterns], - documentExcludes: [...(options.documentExcludes ?? config.documentExcludes)], - adapters: { - python: { enabled: config.enablePythonAst, executable: config.pythonExecutable }, - go: { enabled: config.enableGoAst, executable: config.goExecutable }, - java: { enabled: config.enableJavaAst, executable: config.javaExecutable }, - rust: { enabled: config.enableRustAst, executable: config.cargoExecutable }, - php: { enabled: config.enablePhpAst, executable: config.phpExecutable }, - tensorflow: { - enabled: config.enableTensorFlow, - modelPath: config.tensorflowModelPath, - modulePath: config.tensorflowModulePath, - labels: [...config.tensorflowLabels], - }, - }, - llm: { - configured: hasOpenRouter(config), - baseUrl: config.openRouter.baseUrl, - nlModel: config.openRouter.nlModel, - markdownModel: config.openRouter.markdownModel, - communicationModel: config.openRouter.communicationModel, - documentModel: config.openRouter.documentModel, - summaryModel: config.openRouter.summaryModel, - taskModel: config.openRouter.taskModel, - timeoutMs: config.openRouter.timeoutMs, - maxTokens: config.openRouter.maxTokens, - temperature: config.openRouter.temperature, - requireStructuredOutput: config.openRouter.requireStructuredOutput, - responseHealing: config.openRouter.responseHealing, - }, - }; - return { fingerprint: sha256(stableStringify(configuration)), ...configuration }; -} - -function collectTargetHints(records: IntentRecord[]): { paths: string[]; symbols: string[]; tickets: string[]; versions: string[] } { - const values = (key: K): string[] => [ - ...new Set(records.flatMap((record) => record.statement.target[key])), - ].slice(0, 200); - return { - paths: values('paths'), - symbols: values('symbols'), - tickets: values('tickets'), - versions: values('versions'), - }; -} - -async function persistFailedRun( - runId: string, - root: string, - runDirectory: string, - options: PipelineOptions, - config: T2CConfig, - error: unknown, - failedStage: PipelineFailureStage, - completedStages: Partial, -): Promise { - const aborted = (stage: string): PipelineStageAudit => ({ - ...skippedAudit('disabled', `Pipeline aborted before ${stage}`), - reason: { code: 'PIPELINE_ABORTED', message: `Pipeline aborted before ${stage}` }, - }); - const message = error instanceof Error ? error.message : String(error); - const knownAudit = error instanceof NlLlmRequiredError - || error instanceof MarkdownLlmRequiredError - || error instanceof DocumentationLlmRequiredError - || error instanceof CommunicationLlmRequiredError - || error instanceof TaskSynthesisRequiredError - ? error.audit - : null; - const failedAudit = (stage: keyof PipelineManifest['stages']): PipelineStageAudit => { - if (knownAudit && stage === failedStage) return knownAudit; - return { - runtimeVersion: T2C_VERSION, - configuration: openRouterAuditConfiguration( - config, - stage === 'summary' ? config.openRouter.summaryModel : stage === 'taskSynthesis' ? config.openRouter.taskModel : null, - ), - status: 'failed', requestedMode: stage === 'summary' || stage === 'taskSynthesis' ? 'llm' : 'disabled', effectiveMode: 'none', degraded: true, - recordCount: 0, warningCount: 1, - model: stage === 'summary' ? config.openRouter.summaryModel : stage === 'taskSynthesis' ? config.openRouter.taskModel : null, - durationMs: 0, - reason: { code: failureCode(failedStage), message }, - responses: [], - }; - }; - const stageValue = (stage: keyof PipelineManifest['stages'], label: string): PipelineStageAudit => { - if (completedStages[stage]) return completedStages[stage]; - if (stage === failedStage) return failedAudit(stage); - return aborted(label); - }; - const stages: PipelineManifest['stages'] = { - naturalLanguageExtraction: stageValue('naturalLanguageExtraction', 'natural-language extraction'), - markdownExtraction: stageValue('markdownExtraction', 'Markdown extraction'), - documentationExtraction: stageValue('documentationExtraction', 'documentation extraction'), - communicationAnalysis: stageValue('communicationAnalysis', 'communication analysis'), - taskSynthesis: stageValue('taskSynthesis', 'task synthesis'), - codeChangePlanning: stageValue('codeChangePlanning', 'code-change planning'), - summary: stageValue('summary', 'summary generation'), - }; - const reason = knownAudit?.reason ?? { code: failureCode(failedStage), message }; - const manifest: PipelineManifest = { - schemaVersion: 't2c.run/v1', - runId, - root, - createdAt: new Date().toISOString(), - graphFingerprint: null, - files: { - eventLog: path.relative(root, path.join(runDirectory, 'logs.dsl.txt')).replace(/\\/g, '/'), - }, - warnings: [message], - status: 'failed', - failure: { stage: failedStage, code: reason.code, message: reason.message }, - runtime: { name: 'todo2code', version: T2C_VERSION }, - configuration: manifestConfiguration(options, config), - stages, - llm: { - naturalLanguageExtraction: stages.naturalLanguageExtraction.effectiveMode === 'llm', - markdownExtraction: stages.markdownExtraction.effectiveMode === 'llm', - communicationEnrichment: stages.communicationAnalysis.effectiveMode === 'llm', - documentationExtraction: false, - taskSynthesis: false, - summary: false, - }, - }; - await writeJson(path.join(runDirectory, 'manifest.json'), manifest); - await persistPipelineEventLog({ root, runDirectory, manifest, replaceUnfinished: true }); -} - -function failureCode(stage: PipelineFailureStage): string { - return `PIPELINE_${stage.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase()}_FAILED`; -} - -function skippedAudit(requestedMode: PipelineStageAudit['requestedMode'], message: string): PipelineStageAudit { - return { - runtimeVersion: T2C_VERSION, - configuration: {}, - status: 'skipped', requestedMode, effectiveMode: 'none', degraded: false, - recordCount: 0, warningCount: 0, model: null, durationMs: 0, - reason: { code: 'STAGE_SKIPPED', message }, - responses: [], - }; -} - -function appendLlmNotConfigured(report: DiagnosticReport): void { - const diagnostic: Diagnostic = { - id: createIntentId({ code: 'LLM_NOT_CONFIGURED', graph: report.graphFingerprint }, 'DIAG'), - code: 'LLM_NOT_CONFIGURED', - severity: 'warning', - title: 'OpenRouter nie jest skonfigurowany', - detail: 'Etap dokumentacja -> Intent DSL został pominięty, ponieważ brakuje OPENROUTER_API_KEY.', - recordIds: [], - suggestedAction: 'Ustawić OPENROUTER_API_KEY w .env i ponownie uruchomić pipeline.', - }; - report.diagnostics.unshift(diagnostic); - report.counts.warning += 1; -} +export type { PipelineResult } from './run-types.js'; +export { runPipeline } from './pipeline-run.js'; diff --git a/src/semantic/reranker-response.ts b/src/semantic/reranker-response.ts index 6d4d987b..2aa47487 100644 --- a/src/semantic/reranker-response.ts +++ b/src/semantic/reranker-response.ts @@ -1,10 +1,10 @@ import { structuredSchema as s, type StructuredSchema } from '../llm/structured-schema.js'; import { - assertSemanticVerdictReason, SEMANTIC_RERANK_REASONS, SEMANTIC_RERANK_VERDICTS, type SemanticRerankDecisionInput, -} from './reranker.js'; +} from './reranker-types.js'; +import { assertSemanticVerdictReason } from './reranker-validate.js'; export interface SemanticRerankerResponse { decisions: SemanticRerankDecisionInput[]; diff --git a/src/semantic/reranker-types.ts b/src/semantic/reranker-types.ts new file mode 100644 index 00000000..88d81e15 --- /dev/null +++ b/src/semantic/reranker-types.ts @@ -0,0 +1,106 @@ +export type SemanticRerankVerdict = 'accept' | 'reject' | 'abstain'; +export type SemanticRerankReason = + | 'repository_evidence_supports_match' + | 'wrong_target' + | 'contradicted' + | 'insufficient_evidence' + | 'ambiguous' + | 'multi_module'; + +export const SEMANTIC_RERANK_VERDICTS = ['accept', 'reject', 'abstain'] as const; +export const SEMANTIC_RERANK_REASONS = [ + 'repository_evidence_supports_match', + 'wrong_target', + 'contradicted', + 'insufficient_evidence', + 'ambiguous', + 'multi_module', +] as const; + +export interface SemanticRetrievalIdentity { + provider: string; + model: string; + revision: string; + metric: string; + inputHash: string; +} + +export interface SemanticCandidate { + id: string; + declarationRecordId: string; + moduleRecordId: string; + score: number; + rank: number; +} + +export interface SemanticCandidateSet { + schemaVersion: 't2c.semantic-candidate-set/v1'; + generatedAt: string; + graphFingerprint: string; + maxCandidatesPerDeclaration: number; + retrieval: SemanticRetrievalIdentity; + candidates: SemanticCandidate[]; + candidateSetHash: string; +} + +export interface SemanticCandidateInput { + declarationRecordId: string; + moduleRecordId: string; + score: number; +} + +export interface SemanticRetrievalInput { + provider: string; + model: string; + revision: string; + metric: string; +} + +export interface SemanticEvidenceCitation { + recordId: string; + quote: string; +} + +export interface SemanticRerankDecisionInput { + candidateId: string; + verdict: SemanticRerankVerdict; + confidence: number; + reasonCode: SemanticRerankReason; + rationale: string; + citedRecordIds: string[]; + evidence: SemanticEvidenceCitation[]; +} + +export interface SemanticRerankDecision extends SemanticRerankDecisionInput { + id: string; +} + +export interface SemanticRerankGeneration { + generator: 't2c/cross-language-reranker'; + generatorVersion: '1'; + runtimeVersion: string; + provider: string; + requestedModel: string; + model: string; + modelRevision: string; + responseId: string | null; + promptHash: string; +} + +export interface SemanticRerankResult { + schemaVersion: 't2c.semantic-rerank/v1'; + generatedAt: string; + graphFingerprint: string; + candidateSetHash: string; + generation: SemanticRerankGeneration; + decisions: SemanticRerankDecision[]; + resultHash: string; +} + +export interface SemanticRerankGenerationInput { + provider: string; + requestedModel?: string; + model: string; + modelRevision: string; + responseId?: string | null; +} diff --git a/src/semantic/reranker-validate.ts b/src/semantic/reranker-validate.ts new file mode 100644 index 00000000..0fca9521 --- /dev/null +++ b/src/semantic/reranker-validate.ts @@ -0,0 +1,267 @@ +import { sha256, stableStringify } from '../core/id.js'; +import { assertIntentGraph } from '../core/schema.js'; +import type { IntentGraph, IntentRecord } from '../core/types.js'; +import type { + SemanticCandidate, + SemanticCandidateInput, + SemanticCandidateSet, + SemanticEvidenceCitation, + SemanticRerankDecision, + SemanticRerankGeneration, + SemanticRerankReason, + SemanticRerankResult, + SemanticRerankVerdict, + SemanticRetrievalIdentity, +} from './reranker-types.js'; +import { + SEMANTIC_RERANK_REASONS, + SEMANTIC_RERANK_VERDICTS, +} from './reranker-types.js'; + +function validateSemanticCandidates( + value: SemanticCandidateSet, + graph: IntentGraph, +): Map { + const records = new Map(graph.records.map((record) => [record.id, record])); + const seenIds = new Set(); + const seenPairs = new Set(); + const byDeclaration = new Map(); + for (const candidate of value.candidates) { + if (!/^SCAND-[a-f0-9]{20}$/.test(candidate.id)) throw new Error(`Invalid semantic candidate ID: ${candidate.id}`); + if (seenIds.has(candidate.id)) throw new Error(`Duplicate semantic candidate ID: ${candidate.id}`); + seenIds.add(candidate.id); + const declaration = records.get(candidate.declarationRecordId); + const module = records.get(candidate.moduleRecordId); + if (!declaration || !module) throw new Error(`Semantic candidate ${candidate.id} cites an unknown record`); + if (declaration.statement.kind === 'module_fact') { + throw new Error(`Semantic candidate ${candidate.id} declarationRecordId points to a module`); + } + if (module.statement.kind !== 'module_fact' || module.source.kind !== 'ast') { + throw new Error(`Semantic candidate ${candidate.id} moduleRecordId must point to an AST module_fact`); + } + const pair = `${candidate.declarationRecordId}|${candidate.moduleRecordId}`; + if (seenPairs.has(pair)) throw new Error(`Duplicate semantic candidate pair: ${pair}`); + seenPairs.add(pair); + boundedScore(candidate.score); + if (!Number.isInteger(candidate.rank) || candidate.rank < 1 || candidate.rank > value.maxCandidatesPerDeclaration) { + throw new Error(`Semantic candidate ${candidate.id} has an invalid rank`); + } + const values = byDeclaration.get(candidate.declarationRecordId); + if (values) values.push(candidate); + else byDeclaration.set(candidate.declarationRecordId, [candidate]); + } + return byDeclaration; +} + +function validateSemanticCandidateRanks( + value: SemanticCandidateSet, + byDeclaration: Map, +): void { + for (const [declarationRecordId, candidates] of byDeclaration) { + if (candidates.length > value.maxCandidatesPerDeclaration) { + throw new Error(`Declaration ${declarationRecordId} exceeds the bounded candidate limit`); + } + const ranked = [...candidates].sort((left, right) => left.rank - right.rank); + ranked.forEach((candidate, index) => { + if (candidate.rank !== index + 1) throw new Error(`Declaration ${declarationRecordId} has non-contiguous ranks`); + if (index > 0 && candidate.score > (ranked[index - 1]?.score ?? 1)) { + throw new Error(`Declaration ${declarationRecordId} ranks a higher score below a lower score`); + } + }); + } +} + +export function assertSemanticCandidateSet( + value: SemanticCandidateSet, + graph: IntentGraph, +): void { + assertIntentGraph(graph); + if (value.schemaVersion !== 't2c.semantic-candidate-set/v1') { + throw new Error('Unsupported semantic candidate-set schemaVersion'); + } + validDate(value.generatedAt, 'candidateSet.generatedAt'); + if (value.graphFingerprint !== graph.fingerprint) { + throw new Error('Semantic candidate set graphFingerprint does not match the graph'); + } + if (!Number.isInteger(value.maxCandidatesPerDeclaration) + || value.maxCandidatesPerDeclaration < 1 + || value.maxCandidatesPerDeclaration > 10) { + throw new Error('candidateSet.maxCandidatesPerDeclaration must be an integer between 1 and 10'); + } + validateRetrieval(value.retrieval); + const byDeclaration = validateSemanticCandidates(value, graph); + validateSemanticCandidateRanks(value, byDeclaration); + const expectedHash = sha256(stableStringify({ + graphFingerprint: value.graphFingerprint, + maxCandidatesPerDeclaration: value.maxCandidatesPerDeclaration, + retrieval: value.retrieval, + candidates: value.candidates, + })); + if (value.candidateSetHash !== expectedHash) throw new Error('Semantic candidateSetHash does not match its content'); +} + +function validateSemanticDecisions( + value: SemanticRerankResult, + candidateSet: SemanticCandidateSet, + graph: IntentGraph, +): void { + const candidates = new Map(candidateSet.candidates.map((candidate) => [candidate.id, candidate])); + const records = new Map(graph.records.map((record) => [record.id, record])); + const seenDecisions = new Set(); + const acceptedDeclarations = new Set(); + for (const decision of value.decisions) { + if (!/^SDEC-[a-f0-9]{20}$/.test(decision.id)) throw new Error(`Invalid semantic decision ID: ${decision.id}`); + if (seenDecisions.has(decision.candidateId)) throw new Error(`Duplicate decision for candidate ${decision.candidateId}`); + seenDecisions.add(decision.candidateId); + const candidate = candidates.get(decision.candidateId); + if (!candidate) throw new Error(`Semantic decision cites unknown candidate ${decision.candidateId}`); + roundedConfidence(decision.confidence); + validateVerdictReason(decision); + requiredText(decision.rationale, `Decision ${decision.id} rationale`); + const expectedRecords = [candidate.declarationRecordId, candidate.moduleRecordId].sort(); + const citedRecords = [...new Set(decision.citedRecordIds)].sort(); + if (stableStringify(citedRecords) !== stableStringify(expectedRecords)) { + throw new Error(`Decision ${decision.id} must cite exactly both candidate records`); + } + for (const recordId of expectedRecords) { + const citations = decision.evidence.filter((item) => item.recordId === recordId); + if (citations.length === 0) throw new Error(`Decision ${decision.id} lacks evidence for ${recordId}`); + const record = records.get(recordId); + if (!record) throw new Error(`Decision ${decision.id} cites unknown record ${recordId}`); + for (const citation of citations) assertGroundedQuote(citation, record, decision.id); + } + if (decision.evidence.some((item) => !expectedRecords.includes(item.recordId))) { + throw new Error(`Decision ${decision.id} evidence escapes its candidate pair`); + } + if (decision.verdict === 'accept') { + if (acceptedDeclarations.has(candidate.declarationRecordId)) { + throw new Error(`Reranker accepted more than one module for ${candidate.declarationRecordId}`); + } + acceptedDeclarations.add(candidate.declarationRecordId); + } + } + if (seenDecisions.size !== candidates.size) { + throw new Error('Semantic rerank result must decide every bounded candidate'); + } +} + +export function assertSemanticRerankResult( + value: SemanticRerankResult, + candidateSet: SemanticCandidateSet, + graph: IntentGraph, +): void { + assertSemanticCandidateSet(candidateSet, graph); + if (value.schemaVersion !== 't2c.semantic-rerank/v1') { + throw new Error('Unsupported semantic rerank schemaVersion'); + } + validDate(value.generatedAt, 'rerank.generatedAt'); + if (value.graphFingerprint !== graph.fingerprint || value.candidateSetHash !== candidateSet.candidateSetHash) { + throw new Error('Semantic rerank result does not match its graph or candidate set'); + } + validateGeneration(value.generation); + validateSemanticDecisions(value, candidateSet, graph); + const expectedHash = sha256(stableStringify({ + graphFingerprint: value.graphFingerprint, + candidateSetHash: value.candidateSetHash, + generation: value.generation, + decisions: value.decisions, + })); + if (value.resultHash !== expectedHash) throw new Error('Semantic rerank resultHash does not match its content'); +} + +export function assertSemanticVerdictReason( + verdict: SemanticRerankVerdict, + reasonCode: SemanticRerankReason, + location = 'Semantic decision', +): void { + const allowed = new Set(SEMANTIC_RERANK_VERDICTS); + if (!allowed.has(verdict)) throw new Error(`${location} has invalid verdict`); + const reasons = new Set(SEMANTIC_RERANK_REASONS); + if (!reasons.has(reasonCode)) throw new Error(`${location} has invalid reasonCode`); + if (verdict === 'accept' && reasonCode !== 'repository_evidence_supports_match') { + throw new Error(`${location}: accept requires repository_evidence_supports_match`); + } + if (verdict !== 'accept' && reasonCode === 'repository_evidence_supports_match') { + throw new Error(`${location}: non-accept cannot use repository_evidence_supports_match`); + } +} + +export function boundedScore(value: number): number { + if (!Number.isFinite(value) || value < -1 || value > 1) { + throw new Error('Semantic retrieval score must be a finite number between -1 and 1'); + } + return Math.round(value * 1_000_000) / 1_000_000; +} + +export function roundedConfidence(value: number): number { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new Error('Semantic rerank confidence must be a finite number between 0 and 1'); + } + return Math.round(value * 1_000_000) / 1_000_000; +} + +export function requiredText(value: string, name: string): string { + if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} must be non-blank`); + return value.trim(); +} + +export function comparePair( + left: Pick, + right: Pick, +): number { + return left.declarationRecordId.localeCompare(right.declarationRecordId) + || left.moduleRecordId.localeCompare(right.moduleRecordId); +} + +function validateRetrieval(value: SemanticRetrievalIdentity): void { + requiredText(value.provider, 'retrieval.provider'); + requiredText(value.model, 'retrieval.model'); + requiredText(value.revision, 'retrieval.revision'); + requiredText(value.metric, 'retrieval.metric'); + if (!/^[a-f0-9]{64}$/.test(value.inputHash)) throw new Error('retrieval.inputHash must be SHA-256'); +} + +function validateGeneration(value: SemanticRerankGeneration): void { + if (value.generator !== 't2c/cross-language-reranker' || value.generatorVersion !== '1') { + throw new Error('Unsupported semantic reranker generator'); + } + requiredText(value.runtimeVersion, 'generation.runtimeVersion'); + requiredText(value.provider, 'generation.provider'); + requiredText(value.requestedModel, 'generation.requestedModel'); + requiredText(value.model, 'generation.model'); + requiredText(value.modelRevision, 'generation.modelRevision'); + if (value.responseId !== null) requiredText(value.responseId, 'generation.responseId'); + if (!/^[a-f0-9]{64}$/.test(value.promptHash)) throw new Error('generation.promptHash must be SHA-256'); +} + +function validateVerdictReason(decision: SemanticRerankDecision): void { + assertSemanticVerdictReason( + decision.verdict, + decision.reasonCode, + `Decision ${decision.id}`, + ); +} + +function assertGroundedQuote( + citation: SemanticEvidenceCitation, + record: IntentRecord, + decisionId: string, +): void { + const quote = requiredText(citation.quote, `Decision ${decisionId} evidence.quote`); + const evidence = [ + record.statement.text, + record.statement.object, + ...record.statement.target.paths, + ...record.statement.target.symbols, + ...(Array.isArray(record.metadata.capabilities) + ? record.metadata.capabilities.filter((item): item is string => typeof item === 'string') + : []), + ].join('\n').toLowerCase(); + if (!evidence.includes(quote.toLowerCase())) { + throw new Error(`Decision ${decisionId} quote is not grounded in record ${record.id}`); + } +} + +function validDate(value: string, name: string): void { + if (!value || Number.isNaN(Date.parse(value))) throw new Error(`${name} must be an ISO date-time`); +} diff --git a/src/semantic/reranker.ts b/src/semantic/reranker.ts index de6a512d..d7a8a069 100644 --- a/src/semantic/reranker.ts +++ b/src/semantic/reranker.ts @@ -1,114 +1,53 @@ import { createRelationId, graphFingerprint, sha256, stableStringify } from '../core/id.js'; import { assertIntentGraph } from '../core/schema.js'; -import type { IntentGraph, IntentRecord, IntentRelation } from '../core/types.js'; +import type { IntentGraph, IntentRelation } from '../core/types.js'; import { T2C_VERSION } from '../version.js'; - -export type SemanticRerankVerdict = 'accept' | 'reject' | 'abstain'; -export type SemanticRerankReason = - | 'repository_evidence_supports_match' - | 'wrong_target' - | 'contradicted' - | 'insufficient_evidence' - | 'ambiguous' - | 'multi_module'; - -export const SEMANTIC_RERANK_VERDICTS = ['accept', 'reject', 'abstain'] as const; -export const SEMANTIC_RERANK_REASONS = [ - 'repository_evidence_supports_match', - 'wrong_target', - 'contradicted', - 'insufficient_evidence', - 'ambiguous', - 'multi_module', -] as const; - -export interface SemanticRetrievalIdentity { - provider: string; - model: string; - revision: string; - metric: string; - inputHash: string; -} - -export interface SemanticCandidate { - id: string; - declarationRecordId: string; - moduleRecordId: string; - score: number; - rank: number; -} - -export interface SemanticCandidateSet { - schemaVersion: 't2c.semantic-candidate-set/v1'; - generatedAt: string; - graphFingerprint: string; - maxCandidatesPerDeclaration: number; - retrieval: SemanticRetrievalIdentity; - candidates: SemanticCandidate[]; - candidateSetHash: string; -} - -export interface SemanticCandidateInput { - declarationRecordId: string; - moduleRecordId: string; - score: number; -} - -export interface SemanticRetrievalInput { - provider: string; - model: string; - revision: string; - metric: string; -} - -export interface SemanticEvidenceCitation { - recordId: string; - quote: string; -} - -export interface SemanticRerankDecisionInput { - candidateId: string; - verdict: SemanticRerankVerdict; - confidence: number; - reasonCode: SemanticRerankReason; - rationale: string; - citedRecordIds: string[]; - evidence: SemanticEvidenceCitation[]; -} - -export interface SemanticRerankDecision extends SemanticRerankDecisionInput { - id: string; -} - -export interface SemanticRerankGeneration { - generator: 't2c/cross-language-reranker'; - generatorVersion: '1'; - runtimeVersion: string; - provider: string; - requestedModel: string; - model: string; - modelRevision: string; - responseId: string | null; - promptHash: string; -} - -export interface SemanticRerankResult { - schemaVersion: 't2c.semantic-rerank/v1'; - generatedAt: string; - graphFingerprint: string; - candidateSetHash: string; - generation: SemanticRerankGeneration; - decisions: SemanticRerankDecision[]; - resultHash: string; -} - -export interface SemanticRerankGenerationInput { - provider: string; - requestedModel?: string; - model: string; - modelRevision: string; - responseId?: string | null; -} +import type { + SemanticCandidate, + SemanticCandidateInput, + SemanticCandidateSet, + SemanticRerankDecisionInput, + SemanticRerankGeneration, + SemanticRerankGenerationInput, + SemanticRerankResult, + SemanticRetrievalIdentity, + SemanticRetrievalInput, +} from './reranker-types.js'; +import { + assertSemanticCandidateSet, + assertSemanticRerankResult, + boundedScore, + comparePair, + requiredText, + roundedConfidence, +} from './reranker-validate.js'; + +export type { + SemanticCandidate, + SemanticCandidateInput, + SemanticCandidateSet, + SemanticEvidenceCitation, + SemanticRerankDecision, + SemanticRerankDecisionInput, + SemanticRerankGeneration, + SemanticRerankGenerationInput, + SemanticRerankReason, + SemanticRerankResult, + SemanticRerankVerdict, + SemanticRetrievalIdentity, + SemanticRetrievalInput, +} from './reranker-types.js'; + +export { + SEMANTIC_RERANK_REASONS, + SEMANTIC_RERANK_VERDICTS, +} from './reranker-types.js'; + +export { + assertSemanticCandidateSet, + assertSemanticRerankResult, + assertSemanticVerdictReason, +} from './reranker-validate.js'; export function createSemanticCandidateSet( graph: IntentGraph, @@ -181,73 +120,6 @@ export function createSemanticCandidateSet( return result; } -export function assertSemanticCandidateSet( - value: SemanticCandidateSet, - graph: IntentGraph, -): void { - assertIntentGraph(graph); - if (value.schemaVersion !== 't2c.semantic-candidate-set/v1') { - throw new Error('Unsupported semantic candidate-set schemaVersion'); - } - validDate(value.generatedAt, 'candidateSet.generatedAt'); - if (value.graphFingerprint !== graph.fingerprint) { - throw new Error('Semantic candidate set graphFingerprint does not match the graph'); - } - if (!Number.isInteger(value.maxCandidatesPerDeclaration) - || value.maxCandidatesPerDeclaration < 1 - || value.maxCandidatesPerDeclaration > 10) { - throw new Error('candidateSet.maxCandidatesPerDeclaration must be an integer between 1 and 10'); - } - validateRetrieval(value.retrieval); - const records = new Map(graph.records.map((record) => [record.id, record])); - const seenIds = new Set(); - const seenPairs = new Set(); - const byDeclaration = new Map(); - for (const candidate of value.candidates) { - if (!/^SCAND-[a-f0-9]{20}$/.test(candidate.id)) throw new Error(`Invalid semantic candidate ID: ${candidate.id}`); - if (seenIds.has(candidate.id)) throw new Error(`Duplicate semantic candidate ID: ${candidate.id}`); - seenIds.add(candidate.id); - const declaration = records.get(candidate.declarationRecordId); - const module = records.get(candidate.moduleRecordId); - if (!declaration || !module) throw new Error(`Semantic candidate ${candidate.id} cites an unknown record`); - if (declaration.statement.kind === 'module_fact') { - throw new Error(`Semantic candidate ${candidate.id} declarationRecordId points to a module`); - } - if (module.statement.kind !== 'module_fact' || module.source.kind !== 'ast') { - throw new Error(`Semantic candidate ${candidate.id} moduleRecordId must point to an AST module_fact`); - } - const pair = `${candidate.declarationRecordId}|${candidate.moduleRecordId}`; - if (seenPairs.has(pair)) throw new Error(`Duplicate semantic candidate pair: ${pair}`); - seenPairs.add(pair); - boundedScore(candidate.score); - if (!Number.isInteger(candidate.rank) || candidate.rank < 1 || candidate.rank > value.maxCandidatesPerDeclaration) { - throw new Error(`Semantic candidate ${candidate.id} has an invalid rank`); - } - const values = byDeclaration.get(candidate.declarationRecordId); - if (values) values.push(candidate); - else byDeclaration.set(candidate.declarationRecordId, [candidate]); - } - for (const [declarationRecordId, candidates] of byDeclaration) { - if (candidates.length > value.maxCandidatesPerDeclaration) { - throw new Error(`Declaration ${declarationRecordId} exceeds the bounded candidate limit`); - } - const ranked = [...candidates].sort((left, right) => left.rank - right.rank); - ranked.forEach((candidate, index) => { - if (candidate.rank !== index + 1) throw new Error(`Declaration ${declarationRecordId} has non-contiguous ranks`); - if (index > 0 && candidate.score > (ranked[index - 1]?.score ?? 1)) { - throw new Error(`Declaration ${declarationRecordId} ranks a higher score below a lower score`); - } - }); - } - const expectedHash = sha256(stableStringify({ - graphFingerprint: value.graphFingerprint, - maxCandidatesPerDeclaration: value.maxCandidatesPerDeclaration, - retrieval: value.retrieval, - candidates: value.candidates, - })); - if (value.candidateSetHash !== expectedHash) throw new Error('Semantic candidateSetHash does not match its content'); -} - export function createSemanticRerankResult( graph: IntentGraph, candidateSet: SemanticCandidateSet, @@ -290,7 +162,7 @@ export function createSemanticRerankResult( return { id: `SDEC-${sha256(stableStringify(seed)).slice(0, 20)}`, ...seed, - } satisfies SemanticRerankDecision; + }; }).sort((left, right) => left.candidateId.localeCompare(right.candidateId)); const payload = { graphFingerprint: graph.fingerprint, @@ -308,67 +180,6 @@ export function createSemanticRerankResult( return result; } -export function assertSemanticRerankResult( - value: SemanticRerankResult, - candidateSet: SemanticCandidateSet, - graph: IntentGraph, -): void { - assertSemanticCandidateSet(candidateSet, graph); - if (value.schemaVersion !== 't2c.semantic-rerank/v1') { - throw new Error('Unsupported semantic rerank schemaVersion'); - } - validDate(value.generatedAt, 'rerank.generatedAt'); - if (value.graphFingerprint !== graph.fingerprint || value.candidateSetHash !== candidateSet.candidateSetHash) { - throw new Error('Semantic rerank result does not match its graph or candidate set'); - } - validateGeneration(value.generation); - const candidates = new Map(candidateSet.candidates.map((candidate) => [candidate.id, candidate])); - const records = new Map(graph.records.map((record) => [record.id, record])); - const seenDecisions = new Set(); - const acceptedDeclarations = new Set(); - for (const decision of value.decisions) { - if (!/^SDEC-[a-f0-9]{20}$/.test(decision.id)) throw new Error(`Invalid semantic decision ID: ${decision.id}`); - if (seenDecisions.has(decision.candidateId)) throw new Error(`Duplicate decision for candidate ${decision.candidateId}`); - seenDecisions.add(decision.candidateId); - const candidate = candidates.get(decision.candidateId); - if (!candidate) throw new Error(`Semantic decision cites unknown candidate ${decision.candidateId}`); - roundedConfidence(decision.confidence); - validateVerdictReason(decision); - requiredText(decision.rationale, `Decision ${decision.id} rationale`); - const expectedRecords = [candidate.declarationRecordId, candidate.moduleRecordId].sort(); - const citedRecords = [...new Set(decision.citedRecordIds)].sort(); - if (stableStringify(citedRecords) !== stableStringify(expectedRecords)) { - throw new Error(`Decision ${decision.id} must cite exactly both candidate records`); - } - for (const recordId of expectedRecords) { - const citations = decision.evidence.filter((item) => item.recordId === recordId); - if (citations.length === 0) throw new Error(`Decision ${decision.id} lacks evidence for ${recordId}`); - const record = records.get(recordId); - if (!record) throw new Error(`Decision ${decision.id} cites unknown record ${recordId}`); - for (const citation of citations) assertGroundedQuote(citation, record, decision.id); - } - if (decision.evidence.some((item) => !expectedRecords.includes(item.recordId))) { - throw new Error(`Decision ${decision.id} evidence escapes its candidate pair`); - } - if (decision.verdict === 'accept') { - if (acceptedDeclarations.has(candidate.declarationRecordId)) { - throw new Error(`Reranker accepted more than one module for ${candidate.declarationRecordId}`); - } - acceptedDeclarations.add(candidate.declarationRecordId); - } - } - if (seenDecisions.size !== candidates.size) { - throw new Error('Semantic rerank result must decide every bounded candidate'); - } - const expectedHash = sha256(stableStringify({ - graphFingerprint: value.graphFingerprint, - candidateSetHash: value.candidateSetHash, - generation: value.generation, - decisions: value.decisions, - })); - if (value.resultHash !== expectedHash) throw new Error('Semantic rerank resultHash does not match its content'); -} - export function applyAcceptedSemanticRelations( graph: IntentGraph, candidateSet: SemanticCandidateSet, @@ -410,100 +221,3 @@ export function applyAcceptedSemanticRelations( assertIntentGraph(output); return output; } - -function validateRetrieval(value: SemanticRetrievalIdentity): void { - requiredText(value.provider, 'retrieval.provider'); - requiredText(value.model, 'retrieval.model'); - requiredText(value.revision, 'retrieval.revision'); - requiredText(value.metric, 'retrieval.metric'); - if (!/^[a-f0-9]{64}$/.test(value.inputHash)) throw new Error('retrieval.inputHash must be SHA-256'); -} - -function validateGeneration(value: SemanticRerankGeneration): void { - if (value.generator !== 't2c/cross-language-reranker' || value.generatorVersion !== '1') { - throw new Error('Unsupported semantic reranker generator'); - } - requiredText(value.runtimeVersion, 'generation.runtimeVersion'); - requiredText(value.provider, 'generation.provider'); - requiredText(value.requestedModel, 'generation.requestedModel'); - requiredText(value.model, 'generation.model'); - requiredText(value.modelRevision, 'generation.modelRevision'); - if (value.responseId !== null) requiredText(value.responseId, 'generation.responseId'); - if (!/^[a-f0-9]{64}$/.test(value.promptHash)) throw new Error('generation.promptHash must be SHA-256'); -} - -function validateVerdictReason(decision: SemanticRerankDecision): void { - assertSemanticVerdictReason( - decision.verdict, - decision.reasonCode, - `Decision ${decision.id}`, - ); -} - -export function assertSemanticVerdictReason( - verdict: SemanticRerankVerdict, - reasonCode: SemanticRerankReason, - location = 'Semantic decision', -): void { - const allowed = new Set(SEMANTIC_RERANK_VERDICTS); - if (!allowed.has(verdict)) throw new Error(`${location} has invalid verdict`); - const reasons = new Set(SEMANTIC_RERANK_REASONS); - if (!reasons.has(reasonCode)) throw new Error(`${location} has invalid reasonCode`); - if (verdict === 'accept' && reasonCode !== 'repository_evidence_supports_match') { - throw new Error(`${location}: accept requires repository_evidence_supports_match`); - } - if (verdict !== 'accept' && reasonCode === 'repository_evidence_supports_match') { - throw new Error(`${location}: non-accept cannot use repository_evidence_supports_match`); - } -} - -function assertGroundedQuote( - citation: SemanticEvidenceCitation, - record: IntentRecord, - decisionId: string, -): void { - const quote = requiredText(citation.quote, `Decision ${decisionId} evidence.quote`); - const evidence = [ - record.statement.text, - record.statement.object, - ...record.statement.target.paths, - ...record.statement.target.symbols, - ...(Array.isArray(record.metadata.capabilities) - ? record.metadata.capabilities.filter((item): item is string => typeof item === 'string') - : []), - ].join('\n').toLowerCase(); - if (!evidence.includes(quote.toLowerCase())) { - throw new Error(`Decision ${decisionId} quote is not grounded in record ${record.id}`); - } -} - -function boundedScore(value: number): number { - if (!Number.isFinite(value) || value < -1 || value > 1) { - throw new Error('Semantic retrieval score must be a finite number between -1 and 1'); - } - return Math.round(value * 1_000_000) / 1_000_000; -} - -function roundedConfidence(value: number): number { - if (!Number.isFinite(value) || value < 0 || value > 1) { - throw new Error('Semantic rerank confidence must be a finite number between 0 and 1'); - } - return Math.round(value * 1_000_000) / 1_000_000; -} - -function requiredText(value: string, name: string): string { - if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} must be non-blank`); - return value.trim(); -} - -function validDate(value: string, name: string): void { - if (!value || Number.isNaN(Date.parse(value))) throw new Error(`${name} must be an ISO date-time`); -} - -function comparePair( - left: Pick, - right: Pick, -): number { - return left.declarationRecordId.localeCompare(right.declarationRecordId) - || left.moduleRecordId.localeCompare(right.moduleRecordId); -} diff --git a/src/services/branch-snapshot-git.ts b/src/services/branch-snapshot-git.ts new file mode 100644 index 00000000..9a9d22c5 --- /dev/null +++ b/src/services/branch-snapshot-git.ts @@ -0,0 +1,280 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import type { + BranchGitCandidateSnapshot, + BranchGitInteractionSnapshot, + BranchTextualMergeStatus, + GitCommandOptions, + GitCommandResult, + RefSnapshot, + TextualMergeInspector, +} from './branch-snapshot-types.js'; +import { + MAX_CHANGED_PATHS, + MAX_GIT_OUTPUT, + PATCH_ID, + SHA, +} from './branch-snapshot-types.js'; + +export function classifyMergeTreeResult(exitCode: number, stdout: string): BranchTextualMergeStatus { + if (exitCode === 0 && startsWithSha(stdout)) return 'clean'; + if (exitCode === 1 && startsWithSha(stdout)) return 'conflict'; + return 'unknown'; +} + +export async function repositoryRoot(requestedRoot: string): Promise { + let requested: string; + try { + requested = await fs.realpath(path.resolve(requestedRoot)); + if (!(await fs.stat(requested)).isDirectory()) throw new Error('not a directory'); + } catch { + throw new Error('Branch snapshot root is not a Git work tree'); + } + const inside = await runGit(requested, ['rev-parse', '--is-inside-work-tree']); + if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') { + throw new Error('Branch snapshot root is not a Git work tree'); + } + const result = await requiredGit(requested, ['rev-parse', '--show-toplevel']); + return fs.realpath(result.trim()); +} + +export async function captureRef(root: string, ref: string): Promise { + const symbolic = await runGit(root, ['symbolic-ref', '-q', ref]); + if (symbolic.exitCode === 0) throw new Error(`Symbolic ref is not allowed: ${ref}`); + if (symbolic.exitCode !== 1) throw new Error(`Could not classify ref ${ref}`); + const resolved = (await requiredGit(root, [ + 'rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`, + ])).trim(); + requireSha(resolved, `commit for ${ref}`); + const treeSha = (await requiredGit(root, [ + 'rev-parse', '--verify', '--end-of-options', `${resolved}^{tree}`, + ])).trim(); + requireSha(treeSha, `tree for ${ref}`); + return { ref, sha: resolved, treeSha }; +} + +export async function captureCandidate( + root: string, + base: RefSnapshot, + candidate: RefSnapshot, + inspect: TextualMergeInspector, +): Promise { + const mergeBaseSha = (await requiredGit(root, ['merge-base', base.sha, candidate.sha])).trim(); + requireSha(mergeBaseSha, `merge base for ${candidate.ref}`); + const counts = parseCounts(await requiredGit(root, [ + 'rev-list', '--left-right', '--count', `${base.sha}...${candidate.sha}`, + ]), candidate.ref); + const changedPaths = await readChangedPaths(root, mergeBaseSha, candidate.sha); + return { + ref: candidate.ref, + headSha: candidate.sha, + treeSha: candidate.treeSha, + mergeBaseSha, + aheadBy: counts.right, + behindBy: counts.left, + changedPaths, + patchId: await stablePatchId(root, mergeBaseSha, candidate.sha), + baseTextualMerge: await inspect(root, base.sha, candidate.sha), + }; +} + +export async function captureInteractions( + root: string, + candidates: BranchGitCandidateSnapshot[], + inspect: TextualMergeInspector, +): Promise { + const interactions: BranchGitInteractionSnapshot[] = []; + for (let leftIndex = 0; leftIndex < candidates.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex += 1) { + const left = candidates[leftIndex]; + const right = candidates[rightIndex]; + if (!left || !right) throw new Error('Branch candidate disappeared during pair capture'); + const textualMerge = pathsOverlap(left.changedPaths, right.changedPaths) + ? await inspect(root, left.headSha, right.headSha) + : 'clean'; + interactions.push({ left: left.ref, right: right.ref, textualMerge }); + } + } + return interactions; +} + +export async function isolatedObjectEnvironment( + root: string, + temporaryRoot: string, +): Promise { + const gitCommonDir = (await requiredGit(root, ['rev-parse', '--git-common-dir'])).trim(); + const commonDirectory = path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(root, gitCommonDir); + const sourceObjects = path.join(commonDirectory, 'objects'); + const temporaryObjects = path.join(temporaryRoot, 'objects'); + await fs.mkdir(temporaryObjects, { recursive: true }); + return { + ...process.env, + GIT_OBJECT_DIRECTORY: temporaryObjects, + GIT_ALTERNATE_OBJECT_DIRECTORIES: sourceObjects, + }; +} + +export async function inspectTextualMerge( + root: string, + leftSha: string, + rightSha: string, + env: NodeJS.ProcessEnv, +): Promise { + const result = await runGit(root, [ + 'merge-tree', '--write-tree', '--messages', leftSha, rightSha, + ], { env, maxBuffer: MAX_GIT_OUTPUT }); + return classifyMergeTreeResult(result.exitCode, result.stdout); +} + +export async function assertRefsUnchanged(root: string, captured: Map): Promise { + for (const [ref, snapshot] of captured) { + let current: RefSnapshot; + try { + current = await captureRef(root, ref); + } catch { + throw new Error(`Branch snapshot ref moved or disappeared during capture: ${ref}`); + } + if (current.sha !== snapshot.sha || current.treeSha !== snapshot.treeSha) { + throw new Error(`Branch snapshot ref moved during capture: ${ref}`); + } + } +} + +export const requiredSnapshot = (values: Map, ref: string): RefSnapshot => { + const value = values.get(ref); + if (!value) throw new Error(`Missing captured ref ${ref}`); + return value; +}; + +async function readChangedPaths(root: string, baseSha: string, headSha: string): Promise { + const output = await requiredGit(root, [ + 'diff', '--name-only', '-z', '--no-ext-diff', baseSha, headSha, '--', + ]); + const values = output.split('\0').filter(Boolean).sort(); + if (values.length > MAX_CHANGED_PATHS) { + throw new Error(`Branch changes exceed ${MAX_CHANGED_PATHS} paths`); + } + for (const value of values) { + if (path.isAbsolute(value) || value.split('/').includes('..')) { + throw new Error('Git returned an unsafe repository-relative path'); + } + } + return [...new Set(values)]; +} + +async function stablePatchId(root: string, baseSha: string, headSha: string): Promise { + const diff = await requiredGitBuffer(root, [ + 'diff', '--binary', '--full-index', '--no-ext-diff', baseSha, headSha, '--', + ], { maxBuffer: MAX_GIT_OUTPUT }); + if (diff.length === 0) return null; + const result = await runGit(root, ['patch-id', '--stable'], { + input: diff, + maxBuffer: MAX_GIT_OUTPUT, + }); + if (result.exitCode !== 0) throw gitFailure('patch-id', result); + const patchId = result.stdout.trim().split(/\s+/)[0] ?? ''; + if (!PATCH_ID.test(patchId)) throw new Error('Git returned an invalid stable patch identity'); + return patchId; +} + +const parseCounts = (value: string, ref: string): { left: number; right: number } => { + const parts = value.trim().split(/\s+/).map(Number); + const left = parts[0]; + const right = parts[1]; + if (parts.length !== 2 || !Number.isSafeInteger(left) || !Number.isSafeInteger(right) + || left === undefined || right === undefined || left < 0 || right < 0) { + throw new Error(`Git returned invalid ahead/behind counts for ${ref}`); + } + return { left, right }; +}; + +const pathsOverlap = (left: string[], right: string[]): boolean => { + const rightPaths = new Set(right); + return left.some((value) => rightPaths.has(value)); +}; + +const startsWithSha = (value: string): boolean => { + return SHA.test(value.split(/\r?\n/, 1)[0]?.trim() ?? ''); +}; + +const requireSha = (value: string, name: string): void => { + if (!SHA.test(value)) throw new Error(`Git returned invalid ${name}`); +}; + +async function requiredGit( + root: string, + args: string[], + options: GitCommandOptions = {}, +): Promise { + const result = await runGit(root, args, options); + if (result.exitCode !== 0) throw gitFailure(args[0] ?? 'unknown', result); + return result.stdout; +} + +async function requiredGitBuffer( + root: string, + args: string[], + options: GitCommandOptions = {}, +): Promise { + const result = await runGit(root, args, options); + if (result.exitCode !== 0) throw gitFailure(args[0] ?? 'unknown', result); + return result.stdoutBuffer; +} + +const gitFailure = (command: string, result: GitCommandResult): Error => { + const detail = result.stderr.trim().split(/\r?\n/, 1)[0]?.slice(0, 300); + return new Error(`Git ${command} failed with exit ${result.exitCode}${detail ? `: ${detail}` : ''}`); +}; + +function runGit( + root: string, + args: string[], + options: GitCommandOptions = {}, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn('git', ['-C', root, ...args], { + cwd: root, + env: options.env ?? process.env, + shell: false, + stdio: [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + const maxBuffer = options.maxBuffer ?? 4 * 1024 * 1024; + let bytes = 0; + let overflow = false; + const collect = (target: Buffer[], chunk: Buffer): void => { + bytes += chunk.length; + if (bytes > maxBuffer) { + overflow = true; + child.kill(); + return; + } + target.push(chunk); + }; + child.stdout?.on('data', (chunk: Buffer) => collect(stdout, chunk)); + child.stderr?.on('data', (chunk: Buffer) => collect(stderr, chunk)); + child.once('error', reject); + child.once('close', (code) => { + if (overflow) { + reject(new Error(`Git ${args[0] ?? 'command'} exceeded its output limit`)); + return; + } + const stdoutBuffer = Buffer.concat(stdout); + resolve({ + exitCode: code ?? 128, + stdout: stdoutBuffer.toString('utf8'), + stdoutBuffer, + stderr: Buffer.concat(stderr).toString('utf8'), + }); + }); + if (child.stdin) { + child.stdin.on('error', (error: NodeJS.ErrnoException) => { + if (error.code !== 'EPIPE') reject(error); + }); + child.stdin.end(options.input); + } + }); +} diff --git a/src/services/branch-snapshot-types.ts b/src/services/branch-snapshot-types.ts new file mode 100644 index 00000000..69ef5d5c --- /dev/null +++ b/src/services/branch-snapshot-types.ts @@ -0,0 +1,83 @@ +export type BranchTextualMergeStatus = 'clean' | 'conflict' | 'unknown'; + +export interface BranchGitSnapshotOptions { + root: string; + repository: string; + toolVersion: string; + baseRef: string; + candidateRefs: string[]; +} + +export interface BranchGitBaseSnapshot { + ref: string; + sha: string; + treeSha: string; +} + +export interface BranchGitCandidateSnapshot { + ref: string; + headSha: string; + treeSha: string; + mergeBaseSha: string; + aheadBy: number; + behindBy: number; + changedPaths: string[]; + patchId: string | null; + baseTextualMerge: BranchTextualMergeStatus; +} + +export interface BranchGitInteractionSnapshot { + left: string; + right: string; + textualMerge: BranchTextualMergeStatus; +} + +export interface BranchGitMaterialization { + repository: string; + toolVersion: string; + base: BranchGitBaseSnapshot; + candidates: BranchGitCandidateSnapshot[]; + interactions: BranchGitInteractionSnapshot[]; + fingerprint: string; +} + +export interface BranchSnapshotHooks { + /** Test seam used to prove that a moved ref invalidates the capture. */ + afterCapture?: () => Promise; + /** Test-only parent used to assert temporary-state cleanup. */ + temporaryParent?: string; +} + +export interface RefSnapshot { + ref: string; + sha: string; + treeSha: string; +} + +export interface GitCommandOptions { + input?: string | Buffer; + env?: NodeJS.ProcessEnv; + maxBuffer?: number; +} + +export interface GitCommandResult { + exitCode: number; + stdout: string; + stdoutBuffer: Buffer; + stderr: string; +} + +export const SHA = /^[a-f0-9]{40}$/; +export const PATCH_ID = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/; +export const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +export const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +export const FULL_REF = /^refs\/(?:heads|remotes)\/[A-Za-z0-9][A-Za-z0-9._/-]{0,190}$/; +export const MAX_CANDIDATES = 32; +export const MAX_CHANGED_PATHS = 4096; +export const MAX_GIT_OUTPUT = 64 * 1024 * 1024; + +export type TextualMergeInspector = ( + root: string, + leftSha: string, + rightSha: string, +) => Promise; diff --git a/src/services/branch-snapshot-validate.ts b/src/services/branch-snapshot-validate.ts new file mode 100644 index 00000000..2fa1ab21 --- /dev/null +++ b/src/services/branch-snapshot-validate.ts @@ -0,0 +1,220 @@ +import path from 'node:path'; + +import { sha256, stableStringify } from '../core/id.js'; +import type { + BranchGitCandidateSnapshot, + BranchGitInteractionSnapshot, + BranchGitMaterialization, + BranchGitSnapshotOptions, + BranchTextualMergeStatus, +} from './branch-snapshot-types.js'; +import { + FULL_REF, + MAX_CANDIDATES, + MAX_CHANGED_PATHS, + PATCH_ID, + REPOSITORY, + SHA, + VERSION, +} from './branch-snapshot-types.js'; + +export function assertBranchGitMaterialization( + value: unknown, +): asserts value is BranchGitMaterialization { + const materialization = materializationObject(value); + requireMaterializationKeys(materialization, [ + 'repository', 'toolVersion', 'base', 'candidates', 'interactions', 'fingerprint', + ], 'Branch Git materialization'); + validateMaterializationIdentity(materialization); + const candidates = validateMaterializationCandidates(materialization.candidates, materialization.base.ref); + validateMaterializationInteractions(materialization.interactions, candidates); + const { fingerprint, ...content } = materialization; + if (!/^[a-f0-9]{64}$/.test(fingerprint) + || fingerprint !== sha256(stableStringify(content))) { + throw new Error('Branch Git materialization fingerprint does not match content'); + } +} + +export function validateOptions(options: BranchGitSnapshotOptions): void { + if (!options || typeof options !== 'object') throw new Error('Branch snapshot options are required'); + validateSnapshotIdentity(options); + validateCandidateRefs(options); +} + +export function fingerprintMaterialization( + value: Omit, +): BranchGitMaterialization { + return { ...value, fingerprint: sha256(stableStringify(value)) }; +} + +const validateSnapshotIdentity = (options: BranchGitSnapshotOptions): void => { + if (typeof options.root !== 'string' || options.root.trim().length === 0) { + throw new Error('Branch snapshot root must be non-empty'); + } + if (!REPOSITORY.test(options.repository) || options.repository.includes('..')) { + throw new Error('Branch snapshot repository must be owner/name'); + } + if (!VERSION.test(options.toolVersion)) throw new Error('Branch snapshot toolVersion must be semantic version'); + validateRef(options.baseRef, 'baseRef'); +}; + +const validateCandidateRefs = (options: BranchGitSnapshotOptions): void => { + if (!Array.isArray(options.candidateRefs) + || options.candidateRefs.length === 0 + || options.candidateRefs.length > MAX_CANDIDATES) { + throw new Error(`Branch snapshot candidateRefs must contain 1..${MAX_CANDIDATES} items`); + } + const seen = new Set(); + for (const ref of options.candidateRefs) { + validateRef(ref, 'candidateRef'); + if (ref === options.baseRef) throw new Error(`Candidate ref duplicates base ref ${ref}`); + if (seen.has(ref)) throw new Error(`Duplicate candidate ref ${ref}`); + seen.add(ref); + } +}; + +const validateRef = (ref: string, field: string): void => { + const invalid = typeof ref !== 'string' + || !FULL_REF.test(ref) + || ref.includes('..') + || ref.includes('//') + || ref.includes('@{') + || ref.endsWith('/') + || ref.endsWith('.') + || ref.endsWith('.lock'); + if (invalid) throw new Error(`Invalid branch snapshot ${field}: ${String(ref)}`); +}; + +const validateMaterializationIdentity = (value: BranchGitMaterialization): void => { + if (!REPOSITORY.test(value.repository) || value.repository.includes('..')) { + throw new Error('Branch Git materialization repository must be owner/name'); + } + if (!VERSION.test(value.toolVersion)) { + throw new Error('Branch Git materialization toolVersion must be semantic version'); + } + const base = materializationObject(value.base); + requireMaterializationKeys(base, ['ref', 'sha', 'treeSha'], 'Branch Git materialization base'); + validateRef(value.base.ref, 'base ref'); + requireMaterializationSha(value.base.sha, 'base sha'); + requireMaterializationSha(value.base.treeSha, 'base treeSha'); +}; + +const validateMaterializationCandidates = ( + values: BranchGitCandidateSnapshot[], + baseRef: string, +): Map => { + if (!Array.isArray(values) || values.length === 0 || values.length > MAX_CANDIDATES) { + throw new Error(`Branch Git materialization candidates must contain 1..${MAX_CANDIDATES} items`); + } + const candidates = new Map(); + for (const candidate of values) { + validateMaterializationCandidate(candidate, baseRef); + if (candidates.has(candidate.ref)) throw new Error(`Duplicate materialized candidate ${candidate.ref}`); + candidates.set(candidate.ref, candidate); + } + const ordered = [...candidates.keys()].sort(); + if (stableStringify(values.map((candidate) => candidate.ref)) !== stableStringify(ordered)) { + throw new Error('Branch Git materialization candidates must be sorted by ref'); + } + return candidates; +}; + +const validateMaterializationCandidate = ( + candidate: BranchGitCandidateSnapshot, + baseRef: string, +): void => { + const value = materializationObject(candidate); + requireMaterializationKeys(value, [ + 'ref', 'headSha', 'treeSha', 'mergeBaseSha', 'aheadBy', 'behindBy', + 'changedPaths', 'patchId', 'baseTextualMerge', + ], 'Branch Git materialization candidate'); + validateRef(candidate.ref, 'candidate ref'); + if (candidate.ref === baseRef) throw new Error('Materialized candidate duplicates base ref'); + requireMaterializationSha(candidate.headSha, `${candidate.ref} head sha`); + requireMaterializationSha(candidate.treeSha, `${candidate.ref} tree sha`); + requireMaterializationSha(candidate.mergeBaseSha, `${candidate.ref} merge-base sha`); + requireMaterializationCount(candidate.aheadBy, `${candidate.ref} aheadBy`); + requireMaterializationCount(candidate.behindBy, `${candidate.ref} behindBy`); + validateMaterializedPaths(candidate.changedPaths, candidate.ref); + if (candidate.patchId !== null && !PATCH_ID.test(candidate.patchId)) { + throw new Error(`Invalid materialized patch ID for ${candidate.ref}`); + } + requireMaterializationMerge(candidate.baseTextualMerge, `${candidate.ref} base textual merge`); +}; + +const validateMaterializedPaths = (values: string[], ref: string): void => { + if (!Array.isArray(values) || values.length > MAX_CHANGED_PATHS) { + throw new Error(`Materialized changed paths exceed ${MAX_CHANGED_PATHS} for ${ref}`); + } + const canonical = [...new Set(values)].sort(); + if (stableStringify(values) !== stableStringify(canonical)) { + throw new Error(`Materialized changed paths must be sorted and unique for ${ref}`); + } + for (const value of values) { + if (typeof value !== 'string' || !value || path.isAbsolute(value) || value.split('/').includes('..')) { + throw new Error(`Materialized changed path is unsafe for ${ref}`); + } + } +}; + +const validateMaterializationInteractions = ( + values: BranchGitInteractionSnapshot[], + candidates: Map, +): void => { + if (!Array.isArray(values)) throw new Error('Branch Git materialization interactions must be an array'); + const expected = materializationPairKeys([...candidates.keys()]); + const observed: string[] = []; + for (const interaction of values) { + const value = materializationObject(interaction); + requireMaterializationKeys(value, ['left', 'right', 'textualMerge'], 'Branch Git interaction'); + if (!candidates.has(interaction.left) || !candidates.has(interaction.right)) { + throw new Error('Branch Git interaction references an unknown candidate'); + } + if (interaction.left.localeCompare(interaction.right) >= 0) { + throw new Error('Branch Git interaction refs must be distinct and sorted'); + } + requireMaterializationMerge(interaction.textualMerge, 'Branch Git interaction textual merge'); + observed.push(`${interaction.left}\u0000${interaction.right}`); + } + if (stableStringify(observed) !== stableStringify(expected)) { + throw new Error('Branch Git materialization must contain exactly one sorted interaction per candidate pair'); + } +}; + +const materializationPairKeys = (refs: string[]): string[] => { + const ordered = [...refs].sort(); + const keys: string[] = []; + for (let left = 0; left < ordered.length; left += 1) { + for (let right = left + 1; right < ordered.length; right += 1) { + keys.push(`${ordered[left]}\u0000${ordered[right]}`); + } + } + return keys; +}; + +const materializationObject = (value: T | unknown): T => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Branch Git materialization value must be an object'); + } + return value as T; +}; + +const requireMaterializationKeys = (value: object, keys: string[], name: string): void => { + if (stableStringify(Object.keys(value).sort()) !== stableStringify([...keys].sort())) { + throw new Error(`${name} has unexpected or missing fields`); + } +}; + +const requireMaterializationSha = (value: string, name: string): void => { + if (typeof value !== 'string' || !SHA.test(value)) throw new Error(`Invalid materialized ${name}`); +}; + +const requireMaterializationCount = (value: number, name: string): void => { + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid materialized ${name}`); +}; + +const requireMaterializationMerge = (value: BranchTextualMergeStatus, name: string): void => { + if (value !== 'clean' && value !== 'conflict' && value !== 'unknown') { + throw new Error(`Invalid ${name}`); + } +}; diff --git a/src/services/branch-snapshot.ts b/src/services/branch-snapshot.ts index a54c5ef9..f9f22cbe 100644 --- a/src/services/branch-snapshot.ts +++ b/src/services/branch-snapshot.ts @@ -1,103 +1,42 @@ -import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { sha256, stableStringify } from '../core/id.js'; -export type BranchTextualMergeStatus = 'clean' | 'conflict' | 'unknown'; - -export interface BranchGitSnapshotOptions { - root: string; - repository: string; - toolVersion: string; - baseRef: string; - candidateRefs: string[]; -} - -export interface BranchGitBaseSnapshot { - ref: string; - sha: string; - treeSha: string; -} - -export interface BranchGitCandidateSnapshot { - ref: string; - headSha: string; - treeSha: string; - mergeBaseSha: string; - aheadBy: number; - behindBy: number; - changedPaths: string[]; - patchId: string | null; - baseTextualMerge: BranchTextualMergeStatus; -} - -export interface BranchGitInteractionSnapshot { - left: string; - right: string; - textualMerge: BranchTextualMergeStatus; -} - -export interface BranchGitMaterialization { - repository: string; - toolVersion: string; - base: BranchGitBaseSnapshot; - candidates: BranchGitCandidateSnapshot[]; - interactions: BranchGitInteractionSnapshot[]; - fingerprint: string; -} - -export interface BranchSnapshotHooks { - /** Test seam used to prove that a moved ref invalidates the capture. */ - afterCapture?: () => Promise; - /** Test-only parent used to assert temporary-state cleanup. */ - temporaryParent?: string; -} - -interface RefSnapshot { - ref: string; - sha: string; - treeSha: string; -} - -interface GitCommandOptions { - input?: string | Buffer; - env?: NodeJS.ProcessEnv; - maxBuffer?: number; -} - -interface GitCommandResult { - exitCode: number; - stdout: string; - stdoutBuffer: Buffer; - stderr: string; -} - -const SHA = /^[a-f0-9]{40}$/; -const PATCH_ID = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/; -const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; -const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; -const FULL_REF = /^refs\/(?:heads|remotes)\/[A-Za-z0-9][A-Za-z0-9._/-]{0,190}$/; -const MAX_CANDIDATES = 32; -const MAX_CHANGED_PATHS = 4096; -const MAX_GIT_OUTPUT = 64 * 1024 * 1024; - -export function assertBranchGitMaterialization( - value: unknown, -): asserts value is BranchGitMaterialization { - const materialization = materializationObject(value); - requireMaterializationKeys(materialization, [ - 'repository', 'toolVersion', 'base', 'candidates', 'interactions', 'fingerprint', - ], 'Branch Git materialization'); - validateMaterializationIdentity(materialization); - const candidates = validateMaterializationCandidates(materialization.candidates, materialization.base.ref); - validateMaterializationInteractions(materialization.interactions, candidates); - const { fingerprint, ...content } = materialization; - if (!/^[a-f0-9]{64}$/.test(fingerprint) - || fingerprint !== sha256(stableStringify(content))) { - throw new Error('Branch Git materialization fingerprint does not match content'); - } -} +import type { + BranchGitCandidateSnapshot, + BranchGitMaterialization, + BranchGitSnapshotOptions, + BranchSnapshotHooks, + RefSnapshot, +} from './branch-snapshot-types.js'; +import { + assertRefsUnchanged, + captureCandidate, + captureInteractions, + captureRef, + inspectTextualMerge, + isolatedObjectEnvironment, + repositoryRoot, + requiredSnapshot, +} from './branch-snapshot-git.js'; +import { + assertBranchGitMaterialization, + fingerprintMaterialization, + validateOptions, +} from './branch-snapshot-validate.js'; + +export type { + BranchGitBaseSnapshot, + BranchGitCandidateSnapshot, + BranchGitInteractionSnapshot, + BranchGitMaterialization, + BranchGitSnapshotOptions, + BranchSnapshotHooks, + BranchTextualMergeStatus, +} from './branch-snapshot-types.js'; + +export { assertBranchGitMaterialization } from './branch-snapshot-validate.js'; +export { classifyMergeTreeResult } from './branch-snapshot-git.js'; export async function materializeBranchGitSnapshot( options: BranchGitSnapshotOptions, @@ -139,445 +78,3 @@ export async function materializeBranchGitSnapshot( await fs.rm(temporaryRoot, { recursive: true, force: true }); } } - -const validateOptions = (options: BranchGitSnapshotOptions): void => { - if (!options || typeof options !== 'object') throw new Error('Branch snapshot options are required'); - validateSnapshotIdentity(options); - validateCandidateRefs(options); -}; - -const validateSnapshotIdentity = (options: BranchGitSnapshotOptions): void => { - if (typeof options.root !== 'string' || options.root.trim().length === 0) { - throw new Error('Branch snapshot root must be non-empty'); - } - if (!REPOSITORY.test(options.repository) || options.repository.includes('..')) { - throw new Error('Branch snapshot repository must be owner/name'); - } - if (!VERSION.test(options.toolVersion)) throw new Error('Branch snapshot toolVersion must be semantic version'); - validateRef(options.baseRef, 'baseRef'); -}; - -const validateCandidateRefs = (options: BranchGitSnapshotOptions): void => { - if (!Array.isArray(options.candidateRefs) - || options.candidateRefs.length === 0 - || options.candidateRefs.length > MAX_CANDIDATES) { - throw new Error(`Branch snapshot candidateRefs must contain 1..${MAX_CANDIDATES} items`); - } - const seen = new Set(); - for (const ref of options.candidateRefs) { - validateRef(ref, 'candidateRef'); - if (ref === options.baseRef) throw new Error(`Candidate ref duplicates base ref ${ref}`); - if (seen.has(ref)) throw new Error(`Duplicate candidate ref ${ref}`); - seen.add(ref); - } -}; - -const validateRef = (ref: string, field: string): void => { - const invalid = typeof ref !== 'string' - || !FULL_REF.test(ref) - || ref.includes('..') - || ref.includes('//') - || ref.includes('@{') - || ref.endsWith('/') - || ref.endsWith('.') - || ref.endsWith('.lock'); - if (invalid) throw new Error(`Invalid branch snapshot ${field}: ${String(ref)}`); -}; - -async function repositoryRoot(requestedRoot: string): Promise { - let requested: string; - try { - requested = await fs.realpath(path.resolve(requestedRoot)); - if (!(await fs.stat(requested)).isDirectory()) throw new Error('not a directory'); - } catch { - throw new Error('Branch snapshot root is not a Git work tree'); - } - const inside = await runGit(requested, ['rev-parse', '--is-inside-work-tree']); - if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') { - throw new Error('Branch snapshot root is not a Git work tree'); - } - const result = await requiredGit(requested, ['rev-parse', '--show-toplevel']); - return fs.realpath(result.trim()); -} - -async function captureRef(root: string, ref: string): Promise { - const symbolic = await runGit(root, ['symbolic-ref', '-q', ref]); - if (symbolic.exitCode === 0) throw new Error(`Symbolic ref is not allowed: ${ref}`); - if (symbolic.exitCode !== 1) throw new Error(`Could not classify ref ${ref}`); - const resolved = (await requiredGit(root, [ - 'rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`, - ])).trim(); - requireSha(resolved, `commit for ${ref}`); - const treeSha = (await requiredGit(root, [ - 'rev-parse', '--verify', '--end-of-options', `${resolved}^{tree}`, - ])).trim(); - requireSha(treeSha, `tree for ${ref}`); - return { ref, sha: resolved, treeSha }; -} - -async function captureCandidate( - root: string, - base: RefSnapshot, - candidate: RefSnapshot, - inspect: (root: string, leftSha: string, rightSha: string) => Promise, -): Promise { - const mergeBaseSha = (await requiredGit(root, ['merge-base', base.sha, candidate.sha])).trim(); - requireSha(mergeBaseSha, `merge base for ${candidate.ref}`); - const counts = parseCounts(await requiredGit(root, [ - 'rev-list', '--left-right', '--count', `${base.sha}...${candidate.sha}`, - ]), candidate.ref); - const changedPaths = await readChangedPaths(root, mergeBaseSha, candidate.sha); - return { - ref: candidate.ref, - headSha: candidate.sha, - treeSha: candidate.treeSha, - mergeBaseSha, - aheadBy: counts.right, - behindBy: counts.left, - changedPaths, - patchId: await stablePatchId(root, mergeBaseSha, candidate.sha), - baseTextualMerge: await inspect(root, base.sha, candidate.sha), - }; -} - -async function captureInteractions( - root: string, - candidates: BranchGitCandidateSnapshot[], - inspect: (root: string, leftSha: string, rightSha: string) => Promise, -): Promise { - const interactions: BranchGitInteractionSnapshot[] = []; - for (let leftIndex = 0; leftIndex < candidates.length; leftIndex += 1) { - for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex += 1) { - const left = candidates[leftIndex]; - const right = candidates[rightIndex]; - if (!left || !right) throw new Error('Branch candidate disappeared during pair capture'); - const textualMerge = pathsOverlap(left.changedPaths, right.changedPaths) - ? await inspect(root, left.headSha, right.headSha) - : 'clean'; - interactions.push({ left: left.ref, right: right.ref, textualMerge }); - } - } - return interactions; -} - -async function readChangedPaths(root: string, baseSha: string, headSha: string): Promise { - const output = await requiredGit(root, [ - 'diff', '--name-only', '-z', '--no-ext-diff', baseSha, headSha, '--', - ]); - const values = output.split('\0').filter(Boolean).sort(); - if (values.length > MAX_CHANGED_PATHS) { - throw new Error(`Branch changes exceed ${MAX_CHANGED_PATHS} paths`); - } - for (const value of values) { - if (path.isAbsolute(value) || value.split('/').includes('..')) { - throw new Error('Git returned an unsafe repository-relative path'); - } - } - return [...new Set(values)]; -} - -async function stablePatchId(root: string, baseSha: string, headSha: string): Promise { - const diff = await requiredGitBuffer(root, [ - 'diff', '--binary', '--full-index', '--no-ext-diff', baseSha, headSha, '--', - ], { maxBuffer: MAX_GIT_OUTPUT }); - if (diff.length === 0) return null; - const result = await runGit(root, ['patch-id', '--stable'], { - input: diff, - maxBuffer: MAX_GIT_OUTPUT, - }); - if (result.exitCode !== 0) throw gitFailure('patch-id', result); - const patchId = result.stdout.trim().split(/\s+/)[0] ?? ''; - if (!PATCH_ID.test(patchId)) throw new Error('Git returned an invalid stable patch identity'); - return patchId; -} - -async function isolatedObjectEnvironment(root: string, temporaryRoot: string): Promise { - const gitCommonDir = (await requiredGit(root, ['rev-parse', '--git-common-dir'])).trim(); - const commonDirectory = path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(root, gitCommonDir); - const sourceObjects = path.join(commonDirectory, 'objects'); - const temporaryObjects = path.join(temporaryRoot, 'objects'); - await fs.mkdir(temporaryObjects, { recursive: true }); - return { - ...process.env, - GIT_OBJECT_DIRECTORY: temporaryObjects, - GIT_ALTERNATE_OBJECT_DIRECTORIES: sourceObjects, - }; -} - -async function inspectTextualMerge( - root: string, - leftSha: string, - rightSha: string, - env: NodeJS.ProcessEnv, -): Promise { - const result = await runGit(root, [ - 'merge-tree', '--write-tree', '--messages', leftSha, rightSha, - ], { env, maxBuffer: MAX_GIT_OUTPUT }); - return classifyMergeTreeResult(result.exitCode, result.stdout); -} - -export function classifyMergeTreeResult(exitCode: number, stdout: string): BranchTextualMergeStatus { - if (exitCode === 0 && startsWithSha(stdout)) return 'clean'; - if (exitCode === 1 && startsWithSha(stdout)) return 'conflict'; - return 'unknown'; -} - -async function assertRefsUnchanged(root: string, captured: Map): Promise { - for (const [ref, snapshot] of captured) { - let current: RefSnapshot; - try { - current = await captureRef(root, ref); - } catch { - throw new Error(`Branch snapshot ref moved or disappeared during capture: ${ref}`); - } - if (current.sha !== snapshot.sha || current.treeSha !== snapshot.treeSha) { - throw new Error(`Branch snapshot ref moved during capture: ${ref}`); - } - } -} - -const fingerprintMaterialization = ( - value: Omit, -): BranchGitMaterialization => { - return { ...value, fingerprint: sha256(stableStringify(value)) }; -}; - -const validateMaterializationIdentity = (value: BranchGitMaterialization): void => { - if (!REPOSITORY.test(value.repository) || value.repository.includes('..')) { - throw new Error('Branch Git materialization repository must be owner/name'); - } - if (!VERSION.test(value.toolVersion)) { - throw new Error('Branch Git materialization toolVersion must be semantic version'); - } - const base = materializationObject(value.base); - requireMaterializationKeys(base, ['ref', 'sha', 'treeSha'], 'Branch Git materialization base'); - validateRef(value.base.ref, 'base ref'); - requireMaterializationSha(value.base.sha, 'base sha'); - requireMaterializationSha(value.base.treeSha, 'base treeSha'); -}; - -const validateMaterializationCandidates = ( - values: BranchGitCandidateSnapshot[], - baseRef: string, -): Map => { - if (!Array.isArray(values) || values.length === 0 || values.length > MAX_CANDIDATES) { - throw new Error(`Branch Git materialization candidates must contain 1..${MAX_CANDIDATES} items`); - } - const candidates = new Map(); - for (const candidate of values) { - validateMaterializationCandidate(candidate, baseRef); - if (candidates.has(candidate.ref)) throw new Error(`Duplicate materialized candidate ${candidate.ref}`); - candidates.set(candidate.ref, candidate); - } - const ordered = [...candidates.keys()].sort(); - if (stableStringify(values.map((candidate) => candidate.ref)) !== stableStringify(ordered)) { - throw new Error('Branch Git materialization candidates must be sorted by ref'); - } - return candidates; -}; - -const validateMaterializationCandidate = ( - candidate: BranchGitCandidateSnapshot, - baseRef: string, -): void => { - const value = materializationObject(candidate); - requireMaterializationKeys(value, [ - 'ref', 'headSha', 'treeSha', 'mergeBaseSha', 'aheadBy', 'behindBy', - 'changedPaths', 'patchId', 'baseTextualMerge', - ], 'Branch Git materialization candidate'); - validateRef(candidate.ref, 'candidate ref'); - if (candidate.ref === baseRef) throw new Error('Materialized candidate duplicates base ref'); - requireMaterializationSha(candidate.headSha, `${candidate.ref} head sha`); - requireMaterializationSha(candidate.treeSha, `${candidate.ref} tree sha`); - requireMaterializationSha(candidate.mergeBaseSha, `${candidate.ref} merge-base sha`); - requireMaterializationCount(candidate.aheadBy, `${candidate.ref} aheadBy`); - requireMaterializationCount(candidate.behindBy, `${candidate.ref} behindBy`); - validateMaterializedPaths(candidate.changedPaths, candidate.ref); - if (candidate.patchId !== null && !PATCH_ID.test(candidate.patchId)) { - throw new Error(`Invalid materialized patch ID for ${candidate.ref}`); - } - requireMaterializationMerge(candidate.baseTextualMerge, `${candidate.ref} base textual merge`); -}; - -const validateMaterializedPaths = (values: string[], ref: string): void => { - if (!Array.isArray(values) || values.length > MAX_CHANGED_PATHS) { - throw new Error(`Materialized changed paths exceed ${MAX_CHANGED_PATHS} for ${ref}`); - } - const canonical = [...new Set(values)].sort(); - if (stableStringify(values) !== stableStringify(canonical)) { - throw new Error(`Materialized changed paths must be sorted and unique for ${ref}`); - } - for (const value of values) { - if (typeof value !== 'string' || !value || path.isAbsolute(value) || value.split('/').includes('..')) { - throw new Error(`Materialized changed path is unsafe for ${ref}`); - } - } -}; - -const validateMaterializationInteractions = ( - values: BranchGitInteractionSnapshot[], - candidates: Map, -): void => { - if (!Array.isArray(values)) throw new Error('Branch Git materialization interactions must be an array'); - const expected = materializationPairKeys([...candidates.keys()]); - const observed: string[] = []; - for (const interaction of values) { - const value = materializationObject(interaction); - requireMaterializationKeys(value, ['left', 'right', 'textualMerge'], 'Branch Git interaction'); - if (!candidates.has(interaction.left) || !candidates.has(interaction.right)) { - throw new Error('Branch Git interaction references an unknown candidate'); - } - if (interaction.left.localeCompare(interaction.right) >= 0) { - throw new Error('Branch Git interaction refs must be distinct and sorted'); - } - requireMaterializationMerge(interaction.textualMerge, 'Branch Git interaction textual merge'); - observed.push(`${interaction.left}\u0000${interaction.right}`); - } - if (stableStringify(observed) !== stableStringify(expected)) { - throw new Error('Branch Git materialization must contain exactly one sorted interaction per candidate pair'); - } -}; - -const materializationPairKeys = (refs: string[]): string[] => { - const ordered = [...refs].sort(); - const keys: string[] = []; - for (let left = 0; left < ordered.length; left += 1) { - for (let right = left + 1; right < ordered.length; right += 1) { - keys.push(`${ordered[left]}\u0000${ordered[right]}`); - } - } - return keys; -}; - -const materializationObject = (value: T | unknown): T => { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('Branch Git materialization value must be an object'); - } - return value as T; -}; - -const requireMaterializationKeys = (value: object, keys: string[], name: string): void => { - if (stableStringify(Object.keys(value).sort()) !== stableStringify([...keys].sort())) { - throw new Error(`${name} has unexpected or missing fields`); - } -}; - -const requireMaterializationSha = (value: string, name: string): void => { - if (typeof value !== 'string' || !SHA.test(value)) throw new Error(`Invalid materialized ${name}`); -}; - -const requireMaterializationCount = (value: number, name: string): void => { - if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid materialized ${name}`); -}; - -const requireMaterializationMerge = (value: BranchTextualMergeStatus, name: string): void => { - if (value !== 'clean' && value !== 'conflict' && value !== 'unknown') { - throw new Error(`Invalid ${name}`); - } -}; - -const requiredSnapshot = (values: Map, ref: string): RefSnapshot => { - const value = values.get(ref); - if (!value) throw new Error(`Missing captured ref ${ref}`); - return value; -}; - -const parseCounts = (value: string, ref: string): { left: number; right: number } => { - const parts = value.trim().split(/\s+/).map(Number); - const left = parts[0]; - const right = parts[1]; - if (parts.length !== 2 || !Number.isSafeInteger(left) || !Number.isSafeInteger(right) - || left === undefined || right === undefined || left < 0 || right < 0) { - throw new Error(`Git returned invalid ahead/behind counts for ${ref}`); - } - return { left, right }; -}; - -const pathsOverlap = (left: string[], right: string[]): boolean => { - const rightPaths = new Set(right); - return left.some((value) => rightPaths.has(value)); -}; - -const startsWithSha = (value: string): boolean => { - return SHA.test(value.split(/\r?\n/, 1)[0]?.trim() ?? ''); -}; - -const requireSha = (value: string, name: string): void => { - if (!SHA.test(value)) throw new Error(`Git returned invalid ${name}`); -}; - -const requiredGit = async ( - root: string, - args: string[], - options: GitCommandOptions = {}, -): Promise => { - const result = await runGit(root, args, options); - if (result.exitCode !== 0) throw gitFailure(args[0] ?? 'unknown', result); - return result.stdout; -}; - -const requiredGitBuffer = async ( - root: string, - args: string[], - options: GitCommandOptions = {}, -): Promise => { - const result = await runGit(root, args, options); - if (result.exitCode !== 0) throw gitFailure(args[0] ?? 'unknown', result); - return result.stdoutBuffer; -}; - -const gitFailure = (command: string, result: GitCommandResult): Error => { - const detail = result.stderr.trim().split(/\r?\n/, 1)[0]?.slice(0, 300); - return new Error(`Git ${command} failed with exit ${result.exitCode}${detail ? `: ${detail}` : ''}`); -}; - -const runGit = ( - root: string, - args: string[], - options: GitCommandOptions = {}, -): Promise => { - return new Promise((resolve, reject) => { - const child = spawn('git', ['-C', root, ...args], { - cwd: root, - env: options.env ?? process.env, - shell: false, - stdio: [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], - }); - const stdout: Buffer[] = []; - const stderr: Buffer[] = []; - const maxBuffer = options.maxBuffer ?? 4 * 1024 * 1024; - let bytes = 0; - let overflow = false; - const collect = (target: Buffer[], chunk: Buffer): void => { - bytes += chunk.length; - if (bytes > maxBuffer) { - overflow = true; - child.kill(); - return; - } - target.push(chunk); - }; - child.stdout?.on('data', (chunk: Buffer) => collect(stdout, chunk)); - child.stderr?.on('data', (chunk: Buffer) => collect(stderr, chunk)); - child.once('error', reject); - child.once('close', (code) => { - if (overflow) { - reject(new Error(`Git ${args[0] ?? 'command'} exceeded its output limit`)); - return; - } - const stdoutBuffer = Buffer.concat(stdout); - resolve({ - exitCode: code ?? 128, - stdout: stdoutBuffer.toString('utf8'), - stdoutBuffer, - stderr: Buffer.concat(stderr).toString('utf8'), - }); - }); - if (child.stdin) { - child.stdin.on('error', (error: NodeJS.ErrnoException) => { - if (error.code !== 'EPIPE') reject(error); - }); - child.stdin.end(options.input); - } - }); -}; diff --git a/src/synthesis/code-change-plan-acceptance.ts b/src/synthesis/code-change-plan-acceptance.ts new file mode 100644 index 00000000..275192c1 --- /dev/null +++ b/src/synthesis/code-change-plan-acceptance.ts @@ -0,0 +1,124 @@ +import { + assertCodeChangeAcceptance, + assertCodeChangePlanForAcceptance, + assertConclusions, + assertIntentGraph, +} from '../core/schema.js'; +import type { CodeChangeAcceptance, CodeChangeCloseResult } from '../core/types.js'; +import { diagnoseGraph } from '../graph/diagnostics.js'; +import { deterministicGeneration, uniqueSorted } from './code-change-plan-helpers.js'; +import type { CloseCodeChangesOptions, EvaluateCodeChangeAcceptanceOptions } from './code-change-plan-types.js'; + +/** + * Re-diagnose an after graph and decide whether the plan's targeted + * diagnostics cleared without introducing new blocking findings. + * + * Diagnostic IDs are content-bound, so a still-open finding on the same + * records keeps the same ID. Cleared findings simply disappear. + */ +export function evaluateCodeChangeAcceptance( + options: EvaluateCodeChangeAcceptanceOptions, +): CodeChangeAcceptance { + assertIntentGraph(options.before.graph); + assertIntentGraph(options.afterGraph); + assertConclusions([], options.before); + assertCodeChangePlanForAcceptance(options.plan, options.before); + + const afterDiagnostics = options.afterDiagnostics ?? diagnoseGraph( + options.afterGraph, + options.evaluatedAt ?? new Date().toISOString(), + ); + assertConclusions([], { graph: options.afterGraph, diagnostics: afterDiagnostics }); + + const beforeIds = new Set(options.before.diagnostics.diagnostics.map((item) => item.id)); + const afterById = new Map(afterDiagnostics.diagnostics.map((item) => [item.id, item])); + const afterIds = [...afterById.keys()].sort(); + const targeted = options.plan.evidence.diagnosticIds; + const clearedDiagnosticIds = targeted.filter((id) => !afterById.has(id)).sort(); + const remainingDiagnosticIds = targeted.filter((id) => afterById.has(id)).sort(); + const newBlockingDiagnosticIds = afterDiagnostics.diagnostics + .filter((item) => item.severity === 'blocking' && !beforeIds.has(item.id)) + .map((item) => item.id) + .sort(); + + const reasons: string[] = []; + if (remainingDiagnosticIds.length) { + reasons.push( + `Targeted diagnostics still open: ${remainingDiagnosticIds.join(', ')}.`, + ); + } else { + reasons.push('All targeted diagnostics cleared after re-analysis.'); + } + if (newBlockingDiagnosticIds.length) { + reasons.push( + `New blocking diagnostics appeared: ${newBlockingDiagnosticIds.join(', ')}.`, + ); + } else { + reasons.push('No new blocking diagnostics appeared.'); + } + + const accepted = remainingDiagnosticIds.length === 0 && newBlockingDiagnosticIds.length === 0; + if (accepted) { + reasons.push('Acceptance gate passed; human approval is still required before DONE.'); + } else { + reasons.push('Acceptance gate failed.'); + } + + const evaluatedAt = options.evaluatedAt ?? new Date().toISOString(); + const acceptance: CodeChangeAcceptance = { + schemaVersion: 't2c.code-change-acceptance/v1', + planId: options.plan.id, + planHash: options.plan.planHash, + beforeGraphFingerprint: options.before.graph.fingerprint, + afterGraphFingerprint: options.afterGraph.fingerprint, + beforeDiagnosticIds: [...beforeIds].sort(), + afterDiagnosticIds: afterIds, + clearedDiagnosticIds, + remainingDiagnosticIds, + newBlockingDiagnosticIds, + accepted, + reasons: uniqueSorted(reasons), + evaluatedAt, + generation: deterministicGeneration(evaluatedAt, 't2c/code-change-acceptance'), + }; + assertCodeChangeAcceptance(acceptance, { + plan: options.plan, + before: options.before, + after: { graph: options.afterGraph, diagnostics: afterDiagnostics }, + }); + return acceptance; +} + +/** Evaluate a plan set under one timestamp without applying changes or marking DONE. */ +export function closeCodeChanges(options: CloseCodeChangesOptions): CodeChangeCloseResult { + const evaluatedAt = options.evaluatedAt ?? new Date().toISOString(); + if (Number.isNaN(Date.parse(evaluatedAt))) throw new Error('evaluatedAt must be an ISO date-time'); + assertIntentGraph(options.before.graph); + assertIntentGraph(options.afterGraph); + assertConclusions([], options.before); + const afterDiagnostics = options.afterDiagnostics ?? diagnoseGraph(options.afterGraph, evaluatedAt); + assertConclusions([], { graph: options.afterGraph, diagnostics: afterDiagnostics }); + const planIds = options.plans.map((plan) => plan.id); + if (new Set(planIds).size !== planIds.length) throw new Error('Code change close plans must have unique ids'); + + const acceptances = options.plans.map((plan) => evaluateCodeChangeAcceptance({ + plan, + before: options.before, + afterGraph: options.afterGraph, + afterDiagnostics, + evaluatedAt, + })); + const acceptedCount = acceptances.filter((item) => item.accepted).length; + return { + schemaVersion: 't2c.code-change-close-result/v1', + evaluatedAt, + graphFingerprintBefore: options.before.graph.fingerprint, + graphFingerprintAfter: options.afterGraph.fingerprint, + planCount: options.plans.length, + acceptedCount, + rejectedCount: options.plans.length - acceptedCount, + allAccepted: options.plans.length > 0 && acceptedCount === options.plans.length, + acceptances, + generation: deterministicGeneration(evaluatedAt, 't2c/code-change-close-result'), + }; +} diff --git a/src/synthesis/code-change-plan-apply.ts b/src/synthesis/code-change-plan-apply.ts new file mode 100644 index 00000000..a766d306 --- /dev/null +++ b/src/synthesis/code-change-plan-apply.ts @@ -0,0 +1,266 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { sha256 } from '../core/id.js'; +import type { + CodeChangeFileAction, + CodeChangeSourceApplyReceipt, + CodeChangeSourcePatch, +} from '../core/types.js'; +import { ensureDir, pathExists, readJson, readText } from '../core/io.js'; +import { assertPathWithinRoot } from '../core/security.js'; +import { assertGroundedGenerationMetadata } from '../core/schema.js'; +import { + deterministicGeneration, + exactSourcePatchKeys, + exactSourcePatchSet, +} from './code-change-plan-helpers.js'; +import { assertCodeChangeSourcePatch } from './code-change-plan-source-patch.js'; +import type { ApplyCodeChangeSourcePatchOptions, ApplyCodeChangeSourcePatchResult } from './code-change-plan-types.js'; +import { applyUnifiedDiffToText } from './code-change-plan-unified-diff.js'; + +/** + * Apply a fully-diffed source patch after explicit hash approval. + * + * Instruction-only edits (null unifiedDiff) are rejected. Paths must stay + * relative and inside `root`. Re-applying with an existing matching receipt is + * idempotent. + */ +export async function applyCodeChangeSourcePatch( + options: ApplyCodeChangeSourcePatchOptions, +): Promise { + assertCodeChangeSourcePatch(options.patch); + assertSourcePatchApproval(options); + const root = path.resolve(options.root); + const receiptPath = await assertPathWithinRoot(root, path.resolve(options.receiptPath)); + const lock = await acquireSourcePatchLock(receiptPath); + try { + return await applySourcePatchWithLock(options, root, receiptPath); + } finally { + await lock.close(); + await fs.unlink(`${receiptPath}.t2c-apply.lock`).catch(() => undefined); + } +} + +function assertSourcePatchApproval(options: ApplyCodeChangeSourcePatchOptions): void { + if (!options.approval?.actor?.trim()) throw new Error('Explicit source patch approval actor is required'); + if (options.approval.patchHash !== options.patch.patchHash) { + throw new Error('Source patch approval hash does not match the patch'); + } + for (const edit of options.patch.edits) { + if (edit.unifiedDiff === null) { + throw new Error(`Source patch edit ${edit.path} has no unifiedDiff and cannot be applied`); + } + } +} + +async function acquireSourcePatchLock(receiptPath: string): Promise>> { + const lockPath = `${receiptPath}.t2c-apply.lock`; + await ensureDir(path.dirname(receiptPath)); + try { + return await fs.open(lockPath, 'wx'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new Error('Another source patch apply operation is in progress'); + } + throw error; + } +} + +async function applySourcePatchWithLock( + options: ApplyCodeChangeSourcePatchOptions, + root: string, + receiptPath: string, +): Promise { + if (await pathExists(receiptPath)) { + const existing = await readJson(receiptPath, 1024 * 1024); + await assertExistingSourceReceipt(existing, options.patch, root); + return { applied: false, idempotent: true, receipt: existing }; + } + const prepared = await prepareSourcePatchEdits(options.patch, root, receiptPath); + const changed: PreparedSourceEdit[] = []; + try { + for (const edit of prepared) { + if (edit.action === 'delete') await fs.unlink(edit.absolute); + else await atomicWriteRaw(edit.absolute, edit.after); + changed.push(edit); + } + const now = (options.now ?? new Date()).toISOString(); + const fileHashesAfter = Object.fromEntries(prepared + .map((edit): [string, string] => [edit.relative, sha256(edit.after)]) + .sort(([left], [right]) => left.localeCompare(right))); + const receipt: CodeChangeSourceApplyReceipt = { + schemaVersion: 't2c.code-change-source-apply-receipt/v1', + patchId: options.patch.id, + patchHash: options.patch.patchHash, + planId: options.patch.planId, + approvedBy: options.approval!.actor.trim(), + approvedAt: now, + appliedAt: now, + appliedPaths: prepared.map((edit) => edit.relative).sort(), + fileHashesAfter, + generation: deterministicGeneration(now, 't2c/code-change-source-apply'), + }; + assertSourceApplyReceipt(receipt, options.patch); + await atomicWriteRaw(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); + return { applied: true, idempotent: false, receipt }; + } catch (error) { + await rollbackPreparedSourceEdits(changed, error); + throw error; + } +} + +async function prepareSourcePatchEdits( + patch: CodeChangeSourcePatch, + root: string, + receiptPath: string, +): Promise { + const prepared: PreparedSourceEdit[] = []; + for (const edit of patch.edits) { + prepared.push(await prepareSingleSourcePatchEdit(edit, root, receiptPath)); + } + return prepared; +} + +function assertSourcePatchModifyTargetExists( + edit: CodeChangeSourcePatch['edits'][number], + relative: string, + exists: boolean, +): void { + if (edit.action !== 'modify' || exists) return; + const fromEmpty = /(?:^|\n)---\s+\/dev\/null(?:\n|$)/.test(edit.unifiedDiff!) + || /(?:^|\n)@@\s+-0(?:,0)?\s+\+/.test(edit.unifiedDiff!); + if (!fromEmpty) throw new Error(`Source patch modify target does not exist: ${relative}`); +} + +async function assertSourcePatchEditTarget( + edit: CodeChangeSourcePatch['edits'][number], + root: string, + receiptPath: string, +): Promise<{ relative: string; absolute: string; exists: boolean }> { + const relative = edit.path.replace(/\\/g, '/'); + const absolute = await assertPathWithinRoot(root, path.resolve(root, relative)); + if (absolute === receiptPath) { + throw new Error(`Source patch target collides with its receipt path: ${relative}`); + } + const exists = await pathExists(absolute); + if (exists && (await fs.lstat(absolute)).isSymbolicLink()) { + throw new Error(`Refusing to apply through a symlink: ${relative}`); + } + if (edit.action === 'create' && exists) throw new Error(`Source patch create target already exists: ${relative}`); + if (edit.action === 'delete' && !exists) throw new Error(`Source patch delete target does not exist: ${relative}`); + assertSourcePatchModifyTargetExists(edit, relative, exists); + return { relative, absolute, exists }; +} + +async function prepareSingleSourcePatchEdit( + edit: CodeChangeSourcePatch['edits'][number], + root: string, + receiptPath: string, +): Promise { + const { relative, absolute, exists } = await assertSourcePatchEditTarget(edit, root, receiptPath); + const before = exists ? await readText(absolute, 16 * 1024 * 1024) : ''; + const after = applyUnifiedDiffToText(before, edit.unifiedDiff!, relative); + if (edit.action === 'delete' && after !== '') { + throw new Error(`Source patch delete diff must remove the complete file: ${relative}`); + } + return { relative, absolute, action: edit.action, before, after, existed: exists }; +} + +async function rollbackPreparedSourceEdits(changed: PreparedSourceEdit[], error: unknown): Promise { + const rollbackErrors: string[] = []; + for (const edit of [...changed].reverse()) { + try { + if (edit.existed) await atomicWriteRaw(edit.absolute, edit.before); + else await fs.unlink(edit.absolute).catch((failure: NodeJS.ErrnoException) => { + if (failure.code !== 'ENOENT') throw failure; + }); + } catch (rollbackError) { + rollbackErrors.push(`${edit.relative}: ${String(rollbackError)}`); + } + } + if (rollbackErrors.length) { + throw new Error(`Source patch apply failed (${String(error)}); rollback also failed: ${rollbackErrors.join('; ')}`); + } +} + +interface PreparedSourceEdit { + relative: string; + absolute: string; + action: CodeChangeFileAction; + before: string; + after: string; + existed: boolean; +} + +async function assertExistingSourceReceipt( + receipt: CodeChangeSourceApplyReceipt, + patch: CodeChangeSourcePatch, + root: string, +): Promise { + try { + assertSourceApplyReceipt(receipt, patch); + } catch { + throw new Error('A different or invalid source patch receipt already exists at the receipt path'); + } + for (const edit of patch.edits) { + const relative = edit.path.replace(/\\/g, '/'); + const absolute = await assertPathWithinRoot(root, path.resolve(root, relative)); + const exists = await pathExists(absolute); + if (edit.action === 'delete') { + if (exists) throw new Error(`Applied source patch state changed after receipt: ${relative}`); + continue; + } + if (!exists || (await fs.lstat(absolute)).isSymbolicLink()) { + throw new Error(`Applied source patch state changed after receipt: ${relative}`); + } + const current = await readText(absolute, 16 * 1024 * 1024); + if (receipt.fileHashesAfter[relative] !== sha256(current)) { + throw new Error(`Applied source patch state changed after receipt: ${relative}`); + } + } +} + +function assertSourceApplyReceipt(receipt: CodeChangeSourceApplyReceipt, patch: CodeChangeSourcePatch): void { + exactSourcePatchKeys(receipt as unknown as Record, [ + 'schemaVersion', 'patchId', 'patchHash', 'planId', 'approvedBy', 'approvedAt', + 'appliedAt', 'appliedPaths', 'fileHashesAfter', 'generation', + ], 'Code change source apply receipt'); + if (receipt.schemaVersion !== 't2c.code-change-source-apply-receipt/v1' + || receipt.patchId !== patch.id || receipt.patchHash !== patch.patchHash || receipt.planId !== patch.planId) { + throw new Error('Code change source apply receipt does not match its patch'); + } + if (!receipt.approvedBy.trim()) throw new Error('Code change source apply receipt approvedBy is required'); + if (!Number.isFinite(Date.parse(receipt.approvedAt)) || !Number.isFinite(Date.parse(receipt.appliedAt))) { + throw new Error('Code change source apply receipt timestamps must be ISO date-times'); + } + const expectedPaths = patch.edits.map((edit) => edit.path).sort(); + exactSourcePatchSet(receipt.appliedPaths, expectedPaths, 'receipt appliedPaths'); + const hashPaths = Object.keys(receipt.fileHashesAfter).sort(); + exactSourcePatchSet(hashPaths, expectedPaths, 'receipt fileHashesAfter paths'); + if (Object.values(receipt.fileHashesAfter).some((value) => !/^[a-f0-9]{64}$/.test(value))) { + throw new Error('Code change source apply receipt file hashes must be SHA-256'); + } + assertGroundedGenerationMetadata(receipt.generation, 'Code change source apply receipt generation'); + if (receipt.generation.generatedAt !== receipt.appliedAt + || receipt.generation.generator !== 't2c/code-change-source-apply') { + throw new Error('Code change source apply receipt generation does not match the apply operation'); + } +} + +async function atomicWriteRaw(target: string, content: string): Promise { + await ensureDir(path.dirname(target)); + const temporary = `${target}.tmp-${process.pid}-${randomUUID()}`; + try { + await fs.writeFile(temporary, content, 'utf8'); + await fs.rename(temporary, target); + } finally { + await fs.unlink(temporary).catch(() => undefined); + } +} + +/** + * Apply a single-file unified diff to a text buffer. + * Supports standard hunks with space/+/− prefixes. Throws on context mismatch. + */ +export { applyUnifiedDiffToText } from './code-change-plan-unified-diff.js'; diff --git a/src/synthesis/code-change-plan-helpers.ts b/src/synthesis/code-change-plan-helpers.ts new file mode 100644 index 00000000..4bef0052 --- /dev/null +++ b/src/synthesis/code-change-plan-helpers.ts @@ -0,0 +1,117 @@ +import { sha256, stableStringify } from '../core/id.js'; +import type { GroundedGenerationMetadata, TodoPriority } from '../core/types.js'; +import { T2C_VERSION } from '../version.js'; + +export const IMPLEMENTATION_DIAGNOSTIC_CODES = new Set([ + 'PLANNED_NOT_IMPLEMENTED', + 'CHANGELOG_WITHOUT_IMPLEMENTATION', +]); + +export function uniqueSorted(values: string[]): string[] { + return [...new Set(values.map((item) => item.trim()).filter(Boolean))].sort(); +} + +export function deterministicGeneration(generatedAt: string, generator: string): GroundedGenerationMetadata { + return { + generator, + generatorVersion: '1', + runtimeVersion: T2C_VERSION, + generatedAt, + requestedMode: 'deterministic', + effectiveMode: 'deterministic', + degraded: false, + model: null, + provider: null, + responseId: null, + configurationFingerprint: sha256(stableStringify({ + generator, + generatorVersion: '1', + codes: [...IMPLEMENTATION_DIAGNOSTIC_CODES].sort(), + })), + reason: null, + }; +} + +export function priorityRank(priority: TodoPriority): number { + return ({ P0: 0, P1: 1, P2: 2, P3: 3 } as const)[priority]; +} + +export function inline(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +export function renderIds(ids: string[]): string { + return ids.length ? ids.map((id) => `\`${id}\``).join(', ') : '_none_'; +} + +export function exactSourcePatchKeys(value: Record, expected: string[], name: string): void { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new Error(`${name} keys must be exactly: ${wanted.join(', ')}`); + } +} + +export function assertSourcePatchIds(value: unknown, pattern: RegExp, name: string): asserts value is string[] { + if (!Array.isArray(value) || value.length === 0 + || value.some((item) => typeof item !== 'string' || !pattern.test(item))) { + throw new Error(`Source patch ${name} must be a non-empty array of valid IDs`); + } + if (new Set(value).size !== value.length) throw new Error(`Source patch ${name} must be unique`); +} + +export function assertSourcePatchStrings(value: unknown, name: string, emptyAllowed: boolean): asserts value is string[] { + if (!Array.isArray(value) || (!emptyAllowed && value.length === 0) + || value.some((item) => typeof item !== 'string' || !item.trim())) { + throw new Error(`Source patch ${name} must contain ${emptyAllowed ? 'only ' : ''}non-blank strings`); + } + if (new Set(value).size !== value.length) throw new Error(`Source patch ${name} must be unique`); +} + +export function exactSourcePatchSet(actual: string[], expected: string[], name: string): void { + const left = [...new Set(actual)].sort(); + const right = [...new Set(expected)].sort(); + if (left.length !== right.length || left.some((item, index) => item !== right[index])) { + throw new Error(`Source patch ${name} do not match the plan`); + } +} + +/** + * Validate a single-file unified diff body. + * Accepts optional `--- a/path` / `+++ b/path` headers and rejects foreign paths. + */ +function assertUnifiedDiffPathHeaders(headers: string[], expectedPath: string): void { + for (const header of headers) { + if (header === '/dev/null') continue; + const normalizedPath = header.replace(/\\/g, '/'); + if (normalizedPath.startsWith('/') || normalizedPath.split('/').includes('..')) { + throw new Error(`Unified diff for ${expectedPath} uses a non-repository path header: ${normalizedPath}`); + } + if (normalizedPath !== expectedPath && normalizedPath !== `a/${expectedPath}` && normalizedPath !== `b/${expectedPath}`) { + const bare = normalizedPath.split('\t')[0] ?? normalizedPath; + const stripped = bare.replace(/^[ab]\//, ''); + if (stripped !== expectedPath) { + throw new Error(`Unified diff for ${expectedPath} references foreign path: ${normalizedPath}`); + } + } + } +} + +export function normalizeUnifiedDiff(diff: string, expectedPath: string): string { + const normalized = diff.replace(/\r\n/g, '\n'); + if (!normalized.trim()) throw new Error(`Unified diff for ${expectedPath} is empty`); + if (normalized.includes('\0')) throw new Error(`Unified diff for ${expectedPath} contains NUL bytes`); + if (/(?:api[_-]?key|secret|password|private[_-]?key)\s*[:=]\s*['"]?[^'"\s]{8,}/i.test(normalized)) { + throw new Error(`Unified diff for ${expectedPath} appears to contain a secret assignment`); + } + const headers = [...normalized.matchAll(/^(?:---|\+\+\+)\s+(?:[ab]\/)?(.+)$/gm)].map((match) => match[1]!.trim()); + assertUnifiedDiffPathHeaders(headers, expectedPath); + return normalized; +} + +export function splitKeep(text: string): string[] { + if (text === '') return []; + const lines = text.split('\n'); + if (text.endsWith('\n')) lines.pop(); + return lines; +} diff --git a/src/synthesis/code-change-plan-propose.ts b/src/synthesis/code-change-plan-propose.ts new file mode 100644 index 00000000..7548551a --- /dev/null +++ b/src/synthesis/code-change-plan-propose.ts @@ -0,0 +1,306 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { createCodeChangePlanHash, createCodeChangePlanId } from '../core/id.js'; +import { + assertCodeChangePlans, + assertConclusions, + assertIntentGraph, +} from '../core/schema.js'; +import type { + CodeChangeFile, + CodeChangeFileAction, + CodeChangePlan, + Conclusion, + Diagnostic, + IntentRecord, + IntentTarget, + TodoPriority, + TodoProposal, +} from '../core/types.js'; +import { normalizeTarget } from '../core/target.js'; +import { isUsefulCodeChangePath } from './code-change-path.js'; +import { + deterministicGeneration, + IMPLEMENTATION_DIAGNOSTIC_CODES, + uniqueSorted, +} from './code-change-plan-helpers.js'; +import type { ProposeCodeChangePlansOptions, ProposeCodeChangePlansResult } from './code-change-plan-types.js'; + +function buildPlanFromDiagnostic( + diagnostic: Diagnostic, + options: ProposeCodeChangePlansOptions, + generatedAt: string, + recordsById: Map, + proposalsByDiagnostic: Map, + conclusionsByDiagnostic: Map, +): CodeChangePlan | null { + const relatedRecords = diagnostic.recordIds + .map((id) => recordsById.get(id)) + .filter((record): record is IntentRecord => Boolean(record)); + if (!relatedRecords.length) return null; + + const matchingProposals = proposalsByDiagnostic.get(diagnostic.id) ?? []; + const matchingConclusions = conclusionsByDiagnostic.get(diagnostic.id) ?? []; + const target = collectTarget(relatedRecords, matchingProposals); + const changes = buildChanges(target, relatedRecords, diagnostic, options.pathExists); + if (!changes.length) return null; + + const generation = deterministicGeneration(generatedAt, 't2c/code-change-plan'); + const evidence = { + graphFingerprint: options.graph.fingerprint, + recordIds: uniqueSorted(relatedRecords.map((record) => record.id)), + diagnosticIds: [diagnostic.id], + conclusionIds: uniqueSorted(matchingConclusions.map((item) => item.id)), + proposalIds: uniqueSorted(matchingProposals.map((item) => item.id)), + }; + const semantic = { + title: titleFor(diagnostic, relatedRecords), + description: descriptionFor(diagnostic, relatedRecords, target), + priority: priorityFor(diagnostic), + target, + acceptanceCriteria: acceptanceCriteriaFor(diagnostic, target), + changes, + risk: riskFor(diagnostic, changes), + rollback: rollbackFor(changes), + evidence, + }; + const planHash = createCodeChangePlanHash(semantic); + return { + schemaVersion: 't2c.code-change-plan/v1', + id: createCodeChangePlanId(semantic), + planHash, + status: 'proposed', + createdAt: generatedAt, + ...semantic, + confidence: confidenceFor(diagnostic, matchingProposals), + generation, + }; +} + +function validateProposeCodeChangePlanOptions(options: ProposeCodeChangePlansOptions): { + generatedAt: string; + maxPlans: number; +} { + const generatedAt = options.generatedAt ?? new Date().toISOString(); + if (Number.isNaN(Date.parse(generatedAt))) throw new Error('generatedAt must be an ISO date-time'); + const maxPlans = options.maxPlans ?? 50; + if (!Number.isInteger(maxPlans) || maxPlans < 1 || maxPlans > 500) { + throw new Error('maxPlans must be an integer between 1 and 500'); + } + return { generatedAt, maxPlans }; +} + +export function proposeCodeChangePlans( + options: ProposeCodeChangePlansOptions, +): ProposeCodeChangePlansResult { + assertIntentGraph(options.graph); + assertConclusions([], { graph: options.graph, diagnostics: options.diagnostics }); + const { generatedAt, maxPlans } = validateProposeCodeChangePlanOptions(options); + + const conclusions = options.conclusions ?? []; + const proposals = options.proposals ?? []; + const recordsById = new Map(options.graph.records.map((record) => [record.id, record])); + const proposalsByDiagnostic = indexProposalsByDiagnostic(proposals); + const conclusionsByDiagnostic = indexConclusionsByDiagnostic(conclusions); + + const candidates = options.diagnostics.diagnostics + .filter((diagnostic) => IMPLEMENTATION_DIAGNOSTIC_CODES.has(diagnostic.code)) + .sort((left, right) => implementationDiagnosticRank(left) + - implementationDiagnosticRank(right) || left.id.localeCompare(right.id)); + + const plans: CodeChangePlan[] = []; + for (const diagnostic of candidates) { + if (plans.length >= maxPlans) break; + const plan = buildPlanFromDiagnostic( + diagnostic, options, generatedAt, recordsById, proposalsByDiagnostic, conclusionsByDiagnostic, + ); + if (plan) plans.push(plan); + } + + assertCodeChangePlans(plans, { + graph: options.graph, + diagnostics: options.diagnostics, + conclusions, + proposals, + }); + + return { + schemaVersion: 't2c.code-change-plan-set/v1', + plans, + generatedAt, + graphFingerprint: options.graph.fingerprint, + sourceDiagnosticCount: candidates.length, + generation: deterministicGeneration(generatedAt, 't2c/code-change-plan-set'), + }; +} + +/** + * Build the repository probe for {@link ProposeCodeChangePlansOptions.pathExists}. + * + * A path that escapes the analysed root is reported as existing, so an unusual + * value degrades to today's conservative `modify` instead of instructing an + * executor to create a file outside the repository. + */ +export function createRepositoryPathProbe(root: string): (relativePath: string) => boolean { + const base = path.resolve(root); + return (relativePath: string): boolean => { + const absolute = path.resolve(base, relativePath); + if (absolute !== base && !absolute.startsWith(base + path.sep)) return true; + return existsSync(absolute); + }; +} + +function implementationDiagnosticRank(diagnostic: Diagnostic): number { + return diagnostic.code === 'PLANNED_NOT_IMPLEMENTED' ? 0 : 1; +} + +function indexProposalsByDiagnostic(proposals: TodoProposal[]): Map { + const index = new Map(); + for (const proposal of proposals) { + for (const diagnosticId of proposal.diagnosticIds) { + const list = index.get(diagnosticId) ?? []; + list.push(proposal); + index.set(diagnosticId, list); + } + } + return index; +} + +function indexConclusionsByDiagnostic(conclusions: Conclusion[]): Map { + const index = new Map(); + for (const conclusion of conclusions) { + for (const diagnosticId of conclusion.diagnosticIds) { + const list = index.get(diagnosticId) ?? []; + list.push(conclusion); + index.set(diagnosticId, list); + } + } + return index; +} + +function collectTarget(records: IntentRecord[], proposals: TodoProposal[]): IntentTarget { + const paths = new Set(); + const symbols = new Set(); + const tickets = new Set(); + const versions = new Set(); + for (const record of records) { + for (const path of record.statement.target.paths) paths.add(path); + for (const symbol of record.statement.target.symbols) symbols.add(symbol); + for (const ticket of record.statement.target.tickets) tickets.add(ticket); + for (const version of record.statement.target.versions) versions.add(version); + } + for (const proposal of proposals) { + for (const path of proposal.target.paths) paths.add(path); + for (const symbol of proposal.target.symbols) symbols.add(symbol); + for (const ticket of proposal.target.tickets) tickets.add(ticket); + for (const version of proposal.target.versions) versions.add(version); + } + return normalizeTarget({ + paths: [...paths].filter(isUsefulCodeChangePath), + symbols: [...symbols], + tickets: [...tickets], + versions: [...versions], + }); +} + +function buildChanges( + target: IntentTarget, + records: IntentRecord[], + diagnostic: Diagnostic, + pathExistsInRepository?: (relativePath: string) => boolean, +): CodeChangeFile[] { + const symbols = uniqueSorted(target.symbols); + const sourceIntents = uniqueSorted(records.map((record) => record.statement.text)); + const rationale = sourceIntents.length + ? `Implement the source intent: ${sourceIntents.join(' | ')}` + : diagnostic.detail || `Address ${diagnostic.code}.`; + + if (target.paths.length) { + const changes: CodeChangeFile[] = []; + for (const declared of uniqueSorted(target.paths)) { + const normalized = declared.replace(/\\/g, '/'); + const exists = pathExistsInRepository?.(normalized); + if (exists === false && !normalized.includes('/')) continue; + const action: CodeChangeFileAction = exists === false ? 'create' : 'modify'; + changes.push({ path: normalized, action, symbols, rationale }); + } + return changes; + } + + return []; +} + +function titleFor(diagnostic: Diagnostic, records: IntentRecord[]): string { + const record = records[0]; + const object = record?.statement.object?.trim(); + if (object && startsWithImperative(object) && record?.statement.text.trim()) { + return record.statement.text.trim().replace(/[.!?]+$/, ''); + } + if (object) return `Implement ${object}`; + return diagnostic.title.trim() || `Resolve ${diagnostic.code}`; +} + +function startsWithImperative(value: string): boolean { + return /^(?:add|build|change|configure|create|delete|document|fix|implement|preserve|refactor|remove|test|update|validate|verify)\b/i.test(value) + || /^(?:dodać|dodac|naprawić|naprawic|przetestować|przetestowac|usunąć|usunac|utworzyć|utworzyc|wdrożyć|wdrozyc|zmienić|zmienic|zweryfikować|zweryfikowac)\b/i.test(value); +} + +function descriptionFor( + diagnostic: Diagnostic, + records: IntentRecord[], + target: IntentTarget, +): string { + const parts = [ + diagnostic.detail.trim(), + records[0] ? `Source intent: ${records[0].statement.text.trim()}` : '', + target.paths.length ? `Paths: ${target.paths.join(', ')}.` : '', + target.symbols.length ? `Symbols: ${target.symbols.join(', ')}.` : '', + target.tickets.length ? `Tickets: ${target.tickets.join(', ')}.` : '', + ].filter(Boolean); + return parts.join(' '); +} + +function acceptanceCriteriaFor(diagnostic: Diagnostic, target: IntentTarget): string[] { + const criteria = [ + `Re-run todo2code link+diagnose and clear diagnostic ${diagnostic.id} (${diagnostic.code}).`, + 'Do not introduce new blocking diagnostics.', + ]; + if (target.paths.length) { + criteria.push(`Touch only the declared paths: ${uniqueSorted(target.paths).join(', ')}.`); + } + if (target.symbols.length) { + criteria.push(`Provide AST evidence for symbols: ${uniqueSorted(target.symbols).join(', ')}.`); + } + return uniqueSorted(criteria); +} + +function priorityFor(diagnostic: Diagnostic): TodoPriority { + if (diagnostic.severity === 'blocking') return 'P0'; + if (diagnostic.severity === 'review_required') return 'P1'; + if (diagnostic.severity === 'warning') return 'P2'; + return 'P3'; +} + +function confidenceFor(diagnostic: Diagnostic, proposals: TodoProposal[]): number { + if (proposals.length) { + return Math.min(0.92, Math.max(...proposals.map((item) => item.confidence))); + } + if (diagnostic.severity === 'blocking') return 0.88; + if (diagnostic.severity === 'review_required') return 0.8; + return 0.72; +} + +function riskFor(diagnostic: Diagnostic, changes: CodeChangeFile[]): CodeChangePlan['risk'] { + const level = diagnostic.severity === 'blocking' ? 'high' + : diagnostic.severity === 'review_required' ? 'medium' + : 'low'; + const reasons = [ + `Derived from ${diagnostic.severity} diagnostic ${diagnostic.id}.`, + `Touches ${changes.length} declared ${changes.length === 1 ? 'path' : 'paths'}.`, + ]; + return { level, reasons: uniqueSorted(reasons) }; +} + +function rollbackFor(changes: CodeChangeFile[]): string { + return `Revert the proposed changes to ${uniqueSorted(changes.map((item) => item.path)).join(', ')} and re-run todo2code diagnostics.`; +} diff --git a/src/synthesis/code-change-plan-review.ts b/src/synthesis/code-change-plan-review.ts new file mode 100644 index 00000000..1a4dcfcf --- /dev/null +++ b/src/synthesis/code-change-plan-review.ts @@ -0,0 +1,145 @@ +import { sha256 } from '../core/id.js'; +import type { CodeChangePlan, CodeChangeReviewPatch, GroundedGenerationMetadata } from '../core/types.js'; +import { + assertCodeChangePlansForReview, + assertGroundedGenerationMetadata, +} from '../core/schema.js'; +import { + deterministicGeneration, + inline, + priorityRank, + renderIds, +} from './code-change-plan-helpers.js'; +import type { CreateCodeChangeReviewOptions, CreatedCodeChangeReview } from './code-change-plan-types.js'; + +/** + * Render a stable, reviewable Markdown brief for grounded code-change plans. + * + * This is not a source patch and is never applied to the tree. It exists so + * humans and agents share one hash-bound artifact that lists exact paths, + * acceptance criteria, evidence IDs, risk and rollback instructions. + */ +export function createCodeChangeReviewPatch( + options: CreateCodeChangeReviewOptions, +): CreatedCodeChangeReview { + if (typeof options.graphFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(options.graphFingerprint)) { + throw new Error('graphFingerprint must be a SHA-256 hex digest'); + } + assertCodeChangePlansForReview(options.plans, options.graphFingerprint); + const plans = [...options.plans].sort((left, right) => + priorityRank(left.priority) - priorityRank(right.priority) || left.id.localeCompare(right.id)); + const createdAt = options.createdAt ?? new Date().toISOString(); + if (Number.isNaN(Date.parse(createdAt))) throw new Error('createdAt must be an ISO date-time'); + const markdown = renderCodeChangeReviewMarkdown(plans, options.graphFingerprint); + const artifact: CodeChangeReviewPatch = { + schemaVersion: 't2c.code-change-review/v1', + createdAt, + graphFingerprint: options.graphFingerprint, + planIds: plans.map((plan) => plan.id), + planHashes: plans.map((plan) => plan.planHash), + renderedPatchHash: sha256(markdown), + generation: deterministicGeneration(createdAt, 't2c/code-change-review'), + }; + assertCodeChangeReviewPatch(artifact); + return { markdown, artifact }; +} + +export function renderCodeChangeReviewMarkdown( + plans: CodeChangePlan[], + graphFingerprint: string, +): string { + const lines = [ + '', + '# todo2code proposed code changes', + '', + 'This document is a grounded **review brief**, not an auto-applied source patch.', + 'Implement the listed paths in a normal branch, re-run the pipeline, then', + '`t2c evaluate-code-change`. Acceptance still requires human/CI approval before DONE.', + '', + `Graph fingerprint: \`${graphFingerprint}\``, + '', + ]; + if (!plans.length) { + lines.push('_No grounded code-change plans. Open diagnostics either cleared or lack repository paths._', ''); + return lines.join('\n'); + } + let currentPriority: CodeChangePlan['priority'] | null = null; + for (const plan of plans) { + if (plan.priority !== currentPriority) { + if (currentPriority !== null) lines.push(''); + currentPriority = plan.priority; + lines.push(`## ${plan.priority}`, ''); + } + lines.push(`### ${inline(plan.title)} (\`${plan.id}\`)`, ''); + lines.push(`- Plan hash: \`${plan.planHash}\``); + lines.push(`- Risk: **${plan.risk.level}** — ${plan.risk.reasons.map(inline).join('; ')}`); + lines.push(`- Confidence: ${plan.confidence.toFixed(2)}`); + lines.push(`- Description: ${inline(plan.description)}`); + lines.push('- Changes:'); + for (const change of plan.changes) { + const symbols = change.symbols.length ? ` symbols: ${change.symbols.map((item) => `\`${item}\``).join(', ')}` : ''; + lines.push(` - \`${change.action}\` \`${change.path}\`${symbols}`); + lines.push(` - ${inline(change.rationale)}`); + } + lines.push('- Acceptance criteria:'); + for (const criterion of plan.acceptanceCriteria) lines.push(` - [ ] ${inline(criterion)}`); + lines.push(`- Diagnostics: ${renderIds(plan.evidence.diagnosticIds)}`); + lines.push(`- Evidence records: ${renderIds(plan.evidence.recordIds)}`); + if (plan.evidence.proposalIds.length) lines.push(`- TODO proposals: ${renderIds(plan.evidence.proposalIds)}`); + if (plan.evidence.conclusionIds.length) lines.push(`- Conclusions: ${renderIds(plan.evidence.conclusionIds)}`); + lines.push(`- Rollback: ${inline(plan.rollback)}`); + lines.push(''); + } + lines.push('## After implementation', ''); + lines.push('1. Re-run `t2c pipeline` (or extract + link + diagnose) on the changed tree.'); + lines.push('2. `t2c evaluate-code-change --before-graph … --after-graph … --out acceptance.json`.'); + lines.push('3. Require `accepted=true` and human/CI review before marking work DONE.'); + lines.push(''); + return lines.join('\n'); +} + +export function assertCodeChangeReviewPatch(value: unknown): asserts value is CodeChangeReviewPatch { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Code change review patch must be an object'); + } + const artifact = value as Record; + const required = [ + 'schemaVersion', 'createdAt', 'graphFingerprint', 'planIds', 'planHashes', + 'renderedPatchHash', 'generation', + ]; + for (const key of required) { + if (!(key in artifact)) throw new Error(`Code change review patch is missing: ${key}`); + } + if (artifact.schemaVersion !== 't2c.code-change-review/v1') { + throw new Error('Unsupported code change review schemaVersion'); + } + if (typeof artifact.createdAt !== 'string' || Number.isNaN(Date.parse(artifact.createdAt))) { + throw new Error('Code change review createdAt must be an ISO date-time'); + } + if (typeof artifact.graphFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(artifact.graphFingerprint)) { + throw new Error('Code change review graphFingerprint must be SHA-256'); + } + if (typeof artifact.renderedPatchHash !== 'string' || !/^[a-f0-9]{64}$/.test(artifact.renderedPatchHash)) { + throw new Error('Code change review renderedPatchHash must be SHA-256'); + } + if (!Array.isArray(artifact.planIds) || !artifact.planIds.every((id) => typeof id === 'string' && /^CPLAN-[a-f0-9]{20}$/.test(id))) { + throw new Error('Code change review planIds must be CPLAN ids'); + } + if (!Array.isArray(artifact.planHashes) || !artifact.planHashes.every((hash) => typeof hash === 'string' && /^[a-f0-9]{64}$/.test(hash))) { + throw new Error('Code change review planHashes must be SHA-256 digests'); + } + if (artifact.planIds.length !== artifact.planHashes.length) { + throw new Error('Code change review planIds and planHashes must have equal length'); + } + if (new Set(artifact.planIds as string[]).size !== (artifact.planIds as string[]).length) { + throw new Error('Code change review planIds must be unique'); + } + assertGroundedGenerationMetadata(artifact.generation, 'Code change review generation'); + const generation = artifact.generation as GroundedGenerationMetadata; + if (generation.generatedAt !== artifact.createdAt) { + throw new Error('Code change review generation.generatedAt must match createdAt'); + } + if (generation.generator !== 't2c/code-change-review') { + throw new Error('Code change review generation.generator must be t2c/code-change-review'); + } +} diff --git a/src/synthesis/code-change-plan-source-patch.ts b/src/synthesis/code-change-plan-source-patch.ts new file mode 100644 index 00000000..46187a73 --- /dev/null +++ b/src/synthesis/code-change-plan-source-patch.ts @@ -0,0 +1,307 @@ +import { createCodeChangeSourcePatchHash, createCodeChangeSourcePatchId } from '../core/id.js'; +import type { + CodeChangeFile, + CodeChangePlan, + CodeChangeSourceEdit, + CodeChangeSourcePatch, + CodeChangeSourcePatchSet, +} from '../core/types.js'; +import { + assertCodeChangePlansForReview, + assertGroundedGenerationMetadata, +} from '../core/schema.js'; +import { + assertSourcePatchIds, + assertSourcePatchStrings, + deterministicGeneration, + exactSourcePatchKeys, + exactSourcePatchSet, + normalizeUnifiedDiff, + uniqueSorted, +} from './code-change-plan-helpers.js'; +import type { CreateCodeChangeSourcePatchOptions } from './code-change-plan-types.js'; + +/** + * Build a structured source-edit proposal from one grounded code-change plan. + * + * Deterministic by default: each planned file gets an imperative instruction. + * Callers may attach a unified diff per path; the runtime validates path headers + * and rejects traversal / host paths. Nothing is written to the working tree. + */ +export function createCodeChangeSourcePatch( + options: CreateCodeChangeSourcePatchOptions, +): CodeChangeSourcePatch { + const plan = options.plan; + const graphFingerprint = plan?.evidence?.graphFingerprint; + assertCodeChangePlansForReview( + [plan], + typeof graphFingerprint === 'string' ? graphFingerprint : '', + ); + const createdAt = options.createdAt ?? new Date().toISOString(); + if (Number.isNaN(Date.parse(createdAt))) throw new Error('createdAt must be an ISO date-time'); + const allowed = new Set(plan.target.paths.map((path) => path.replace(/\\/g, '/'))); + const diffs = options.unifiedDiffs ?? {}; + for (const path of Object.keys(diffs)) { + const normalized = path.replace(/\\/g, '/'); + if (!allowed.has(normalized)) { + throw new Error(`Unified diff path ${normalized} is not declared by plan ${plan.id}`); + } + } + const edits: CodeChangeSourceEdit[] = [...plan.changes] + .map((change) => { + const path = change.path.replace(/\\/g, '/'); + if (!allowed.has(path)) { + throw new Error(`Edit path ${path} is not present in plan target.paths`); + } + const rawDiff = diffs[path]; + const unifiedDiff = rawDiff === undefined ? null : normalizeUnifiedDiff(rawDiff, path); + return { + path, + action: change.action, + symbols: uniqueSorted(change.symbols), + instruction: instructionFor(change, plan), + unifiedDiff, + }; + }) + .sort((left, right) => left.path.localeCompare(right.path) || left.action.localeCompare(right.action)); + if (!edits.length) throw new Error(`Plan ${plan.id} has no editable paths`); + + const semantic = { + planId: plan.id, + planHash: plan.planHash, + graphFingerprint: plan.evidence.graphFingerprint, + diagnosticIds: uniqueSorted(plan.evidence.diagnosticIds), + recordIds: uniqueSorted(plan.evidence.recordIds), + edits, + acceptanceCriteria: uniqueSorted(plan.acceptanceCriteria), + }; + const patchHash = createCodeChangeSourcePatchHash(semantic); + const patch: CodeChangeSourcePatch = { + schemaVersion: 't2c.code-change-source-patch/v1', + id: createCodeChangeSourcePatchId(semantic), + patchHash, + status: 'proposed', + createdAt, + ...semantic, + generation: deterministicGeneration(createdAt, 't2c/code-change-source-patch'), + }; + assertCodeChangeSourcePatch(patch, plan); + return patch; +} + +export function createCodeChangeSourcePatchSet(options: { + plans: CodeChangePlan[]; + graphFingerprint: string; + unifiedDiffsByPlanId?: Record>; + generatedAt?: string; +}): CodeChangeSourcePatchSet { + if (typeof options.graphFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(options.graphFingerprint)) { + throw new Error('graphFingerprint must be a SHA-256 hex digest'); + } + assertCodeChangePlansForReview(options.plans, options.graphFingerprint); + const generatedAt = options.generatedAt ?? new Date().toISOString(); + const patches = [...options.plans] + .sort((left, right) => left.id.localeCompare(right.id)) + .map((plan) => createCodeChangeSourcePatch({ + plan, + createdAt: generatedAt, + ...(options.unifiedDiffsByPlanId?.[plan.id] + ? { unifiedDiffs: options.unifiedDiffsByPlanId[plan.id] } + : {}), + })); + const result: CodeChangeSourcePatchSet = { + schemaVersion: 't2c.code-change-source-patch-set/v1', + generatedAt, + graphFingerprint: options.graphFingerprint, + patches, + generation: deterministicGeneration(generatedAt, 't2c/code-change-source-patch-set'), + }; + assertCodeChangeSourcePatchSet(result, options.plans); + return result; +} + +function assertCodeChangeSourcePatchIdsAndTimes(patch: CodeChangeSourcePatch): void { + if (typeof patch.id !== 'string' || !/^SPATCH-[a-f0-9]{20}$/.test(patch.id)) { + throw new Error('Source patch id must match SPATCH-<20 hex>'); + } + if (typeof patch.patchHash !== 'string' || !/^[a-f0-9]{64}$/.test(patch.patchHash)) { + throw new Error('Source patch patchHash must be SHA-256'); + } + if (patch.status !== 'proposed') throw new Error('Source patch status must be proposed'); + if (typeof patch.createdAt !== 'string' || Number.isNaN(Date.parse(patch.createdAt))) { + throw new Error('Source patch createdAt must be an ISO date-time'); + } + if (typeof patch.planId !== 'string' || !/^CPLAN-[a-f0-9]{20}$/.test(patch.planId)) { + throw new Error('Source patch planId must match CPLAN-<20 hex>'); + } + if (typeof patch.planHash !== 'string' || !/^[a-f0-9]{64}$/.test(patch.planHash)) { + throw new Error('Source patch planHash must be SHA-256'); + } + if (typeof patch.graphFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(patch.graphFingerprint)) { + throw new Error('Source patch graphFingerprint must be SHA-256'); + } +} + +function assertCodeChangeSourcePatchHeader(patch: CodeChangeSourcePatch): void { + exactSourcePatchKeys(patch as unknown as Record, [ + 'schemaVersion', 'id', 'patchHash', 'status', 'createdAt', 'planId', 'planHash', + 'graphFingerprint', 'diagnosticIds', 'recordIds', 'edits', 'acceptanceCriteria', 'generation', + ], 'Source patch'); + if (patch.schemaVersion !== 't2c.code-change-source-patch/v1') { + throw new Error('Unsupported code change source patch schemaVersion'); + } + assertCodeChangeSourcePatchIdsAndTimes(patch); + if (!Array.isArray(patch.edits) || patch.edits.length === 0) { + throw new Error('Source patch edits must be a non-empty array'); + } + assertSourcePatchIds(patch.diagnosticIds, /^DIAG-[a-f0-9]{20}$/, 'diagnosticIds'); + assertSourcePatchIds(patch.recordIds, /^INT-[A-Z]+-[a-f0-9]{20}$/, 'recordIds'); + assertSourcePatchStrings(patch.acceptanceCriteria, 'acceptanceCriteria', false); +} + +function assertCodeChangeSourcePatchEdits(patch: CodeChangeSourcePatch): void { + const paths = new Set(); + for (const edit of patch.edits) { + if (!edit || typeof edit !== 'object') throw new Error('Source patch edit must be an object'); + exactSourcePatchKeys(edit as unknown as Record, [ + 'path', 'action', 'symbols', 'instruction', 'unifiedDiff', + ], 'Source patch edit'); + const path = edit.path?.trim().replace(/\\/g, '/') ?? ''; + if (!path || path.startsWith('/') || path.split('/').includes('..')) { + throw new Error(`Source patch edit path is not a relative repository path: ${path}`); + } + if (!['create', 'modify', 'delete'].includes(edit.action)) { + throw new Error(`Source patch edit action is unsupported: ${String(edit.action)}`); + } + if (typeof edit.instruction !== 'string' || !edit.instruction.trim()) { + throw new Error('Source patch edit instruction must be non-blank'); + } + assertSourcePatchStrings(edit.symbols, `edits[${path}].symbols`, true); + if (edit.unifiedDiff !== null) { + if (typeof edit.unifiedDiff !== 'string') throw new Error('Source patch unifiedDiff must be string or null'); + normalizeUnifiedDiff(edit.unifiedDiff, path); + } + const key = `${path}::${edit.action}`; + if (paths.has(key)) throw new Error(`Duplicate source patch edit for ${path}`); + paths.add(key); + } +} + +function assertCodeChangeSourcePatchIdentity(patch: CodeChangeSourcePatch): void { + const expectedHash = createCodeChangeSourcePatchHash(patch); + if (patch.patchHash !== expectedHash) { + throw new Error(`Source patch patchHash does not match semantic content: expected ${expectedHash}`); + } + if (patch.id !== createCodeChangeSourcePatchId(patch)) { + throw new Error('Source patch id does not match semantic content'); + } + assertGroundedGenerationMetadata(patch.generation, 'Source patch generation'); + if (patch.generation.generatedAt !== patch.createdAt) { + throw new Error('Source patch generation.generatedAt must match createdAt'); + } + if (patch.generation.generator !== 't2c/code-change-source-patch') { + throw new Error('Source patch generation.generator must be t2c/code-change-source-patch'); + } +} + +function assertCodeChangeSourcePatchPlanBinding(patch: CodeChangeSourcePatch, plan: CodeChangePlan): void { + if (patch.planId !== plan.id || patch.planHash !== plan.planHash) { + throw new Error('Source patch is not bound to the supplied plan'); + } + if (patch.graphFingerprint !== plan.evidence.graphFingerprint) { + throw new Error('Source patch graphFingerprint does not match the plan'); + } + const allowed = new Set(plan.target.paths.map((item) => item.replace(/\\/g, '/'))); + const expectedChanges = new Map(plan.changes.map((item) => [ + item.path.replace(/\\/g, '/'), item.action, + ])); + for (const edit of patch.edits) { + const editPath = edit.path.replace(/\\/g, '/'); + if (!allowed.has(editPath)) { + throw new Error(`Source patch path ${edit.path} is outside plan target.paths`); + } + if (expectedChanges.get(editPath) !== edit.action) { + throw new Error(`Source patch action for ${edit.path} does not match the plan`); + } + } + exactSourcePatchSet(patch.edits.map((item) => item.path.replace(/\\/g, '/')), [...expectedChanges.keys()], 'edit paths'); + exactSourcePatchSet(patch.diagnosticIds, plan.evidence.diagnosticIds, 'diagnosticIds'); + exactSourcePatchSet(patch.recordIds, plan.evidence.recordIds, 'recordIds'); + exactSourcePatchSet(patch.acceptanceCriteria, plan.acceptanceCriteria, 'acceptanceCriteria'); +} + +export function assertCodeChangeSourcePatch( + value: unknown, + plan?: CodeChangePlan, +): asserts value is CodeChangeSourcePatch { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Code change source patch must be an object'); + } + const patch = value as CodeChangeSourcePatch; + assertCodeChangeSourcePatchHeader(patch); + assertCodeChangeSourcePatchEdits(patch); + assertCodeChangeSourcePatchIdentity(patch); + if (plan) assertCodeChangeSourcePatchPlanBinding(patch, plan); +} + +function assertCodeChangeSourcePatchSetHeader(set: CodeChangeSourcePatchSet): void { + exactSourcePatchKeys(set as unknown as Record, [ + 'schemaVersion', 'generatedAt', 'graphFingerprint', 'patches', 'generation', + ], 'Source patch set'); + if (set.schemaVersion !== 't2c.code-change-source-patch-set/v1') { + throw new Error('Unsupported code change source patch set schemaVersion'); + } + if (typeof set.generatedAt !== 'string' || Number.isNaN(Date.parse(set.generatedAt))) { + throw new Error('Source patch set generatedAt must be an ISO date-time'); + } + if (typeof set.graphFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(set.graphFingerprint)) { + throw new Error('Source patch set graphFingerprint must be SHA-256'); + } + if (!Array.isArray(set.patches)) throw new Error('Source patch set patches must be an array'); +} + +function validateSourcePatchSetPatches( + set: CodeChangeSourcePatchSet, + plans?: CodeChangePlan[], +): void { + const plansById = new Map((plans ?? []).map((plan) => [plan.id, plan])); + const patchIds = new Set(); + for (const patch of set.patches) { + assertCodeChangeSourcePatch(patch, plans ? plansById.get(patch.planId) : undefined); + if (patch.graphFingerprint !== set.graphFingerprint) { + throw new Error(`Source patch ${patch.id} graphFingerprint does not match its set`); + } + if (patchIds.has(patch.id)) throw new Error(`Duplicate source patch id: ${patch.id}`); + patchIds.add(patch.id); + } + if (plans) exactSourcePatchSet(set.patches.map((patch) => patch.planId), plans.map((plan) => plan.id), 'planIds'); +} + +export function assertCodeChangeSourcePatchSet( + value: unknown, + plans?: CodeChangePlan[], +): asserts value is CodeChangeSourcePatchSet { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Code change source patch set must be an object'); + } + const set = value as CodeChangeSourcePatchSet; + assertCodeChangeSourcePatchSetHeader(set); + validateSourcePatchSetPatches(set, plans); + assertGroundedGenerationMetadata(set.generation, 'Source patch set generation'); + if (set.generation.generatedAt !== set.generatedAt) { + throw new Error('Source patch set generation.generatedAt must match generatedAt'); + } + if (set.generation.generator !== 't2c/code-change-source-patch-set') { + throw new Error('Source patch set generation.generator must be t2c/code-change-source-patch-set'); + } +} + +function instructionFor(change: CodeChangeFile, plan: CodeChangePlan): string { + const symbols = change.symbols.length + ? ` Focus on symbols: ${change.symbols.join(', ')}.` + : ''; + const criteria = plan.acceptanceCriteria.length + ? ` Acceptance: ${plan.acceptanceCriteria.join(' ')}` + : ''; + return `${change.action} \`${change.path}\`. ${change.rationale.trim()}.${symbols}${criteria}`.replace(/\s+/g, ' ').trim(); +} diff --git a/src/synthesis/code-change-plan-types.ts b/src/synthesis/code-change-plan-types.ts new file mode 100644 index 00000000..4e2c7ce3 --- /dev/null +++ b/src/synthesis/code-change-plan-types.ts @@ -0,0 +1,94 @@ +import type { + CodeChangeAcceptance, + CodeChangeCloseResult, + CodeChangePlan, + CodeChangeReviewPatch, + CodeChangeSourceApplyReceipt, + CodeChangeSourcePatch, + CodeChangeSourcePatchApproval, + CodeChangeSourcePatchSet, + Conclusion, + DiagnosticReport, + GroundedGenerationMetadata, + IntentGraph, + TodoProposal, +} from '../core/types.js'; + +export { isUsefulCodeChangePath } from './code-change-path.js'; + +export interface ProposeCodeChangePlansOptions { + graph: IntentGraph; + diagnostics: DiagnosticReport; + conclusions?: Conclusion[]; + proposals?: TodoProposal[]; + generatedAt?: string; + /** Limit how many plans are materialised from open diagnostics. Default 50. */ + maxPlans?: number; + /** + * Repository probe used to tell `create` from `modify`. Injected rather than + * read here so plan synthesis stays pure and deterministic; when omitted the + * plan cannot know and keeps the conservative `modify`. + * See {@link createRepositoryPathProbe}. + */ + pathExists?: (relativePath: string) => boolean; +} + +export interface ProposeCodeChangePlansResult { + schemaVersion: 't2c.code-change-plan-set/v1'; + plans: CodeChangePlan[]; + generatedAt: string; + graphFingerprint: string; + sourceDiagnosticCount: number; + generation: GroundedGenerationMetadata; +} + +export interface EvaluateCodeChangeAcceptanceOptions { + plan: CodeChangePlan; + /** Graph and diagnostics that the plan was grounded on. */ + before: { graph: IntentGraph; diagnostics: DiagnosticReport }; + /** Graph after an attempted implementation (re-extracted and re-linked). */ + afterGraph: IntentGraph; + /** Optional precomputed after diagnostics; derived when omitted. */ + afterDiagnostics?: DiagnosticReport; + evaluatedAt?: string; +} + +export interface CloseCodeChangesOptions { + plans: CodeChangePlan[]; + before: { graph: IntentGraph; diagnostics: DiagnosticReport }; + afterGraph: IntentGraph; + afterDiagnostics?: DiagnosticReport; + evaluatedAt?: string; +} + +export interface CreateCodeChangeReviewOptions { + plans: CodeChangePlan[]; + graphFingerprint: string; + createdAt?: string; +} + +export interface CreatedCodeChangeReview { + markdown: string; + artifact: CodeChangeReviewPatch; +} + +export interface CreateCodeChangeSourcePatchOptions { + plan: CodeChangePlan; + /** Optional per-path unified diffs keyed by relative repository path. */ + unifiedDiffs?: Record; + createdAt?: string; +} + +export interface ApplyCodeChangeSourcePatchOptions { + root: string; + patch: CodeChangeSourcePatch; + approval: CodeChangeSourcePatchApproval; + receiptPath: string; + now?: Date; +} + +export interface ApplyCodeChangeSourcePatchResult { + applied: boolean; + idempotent: boolean; + receipt: CodeChangeSourceApplyReceipt; +} diff --git a/src/synthesis/code-change-plan-unified-diff.ts b/src/synthesis/code-change-plan-unified-diff.ts new file mode 100644 index 00000000..a87a6df2 --- /dev/null +++ b/src/synthesis/code-change-plan-unified-diff.ts @@ -0,0 +1,104 @@ +import { normalizeUnifiedDiff, splitKeep } from './code-change-plan-helpers.js'; + +interface UnifiedDiffHunk { + oldStart: number; + oldCount: number; + newCount: number; + lines: string[]; +} + +function startUnifiedDiffHunk(header: RegExpExecArray): UnifiedDiffHunk { + return { + oldStart: Number(header[1]), + oldCount: header[2] === undefined ? 1 : Number(header[2]), + newCount: header[4] === undefined ? 1 : Number(header[4]), + lines: [], + }; +} + +function isUnifiedDiffMetaLine(line: string): boolean { + return line.startsWith('---') || line.startsWith('+++') || line.startsWith('diff ') || line.startsWith('index '); +} + +function parseUnifiedDiffHunks(diffLines: string[], expectedPath: string): UnifiedDiffHunk[] { + const hunks: UnifiedDiffHunk[] = []; + let current: UnifiedDiffHunk | null = null; + for (const line of diffLines) { + if (isUnifiedDiffMetaLine(line)) continue; + const header = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line); + if (header) { + if (current) hunks.push(current); + current = startUnifiedDiffHunk(header); + continue; + } + if (!current) { + if (line === '') continue; + throw new Error(`Unified diff for ${expectedPath} has content outside hunks`); + } + if (line !== '') current.lines.push(line); + } + if (current) hunks.push(current); + if (!hunks.length) throw new Error(`Unified diff for ${expectedPath} contains no hunks`); + return hunks; +} + +function applyUnifiedDiffHunk( + hunk: UnifiedDiffHunk, + baseLines: string[], + output: string[], + cursor: { value: number }, + expectedPath: string, +): void { + const oldIndex = Math.max(0, hunk.oldStart - 1); + if (oldIndex < cursor.value) throw new Error(`Unified diff for ${expectedPath} has overlapping or unordered hunks`); + const oldCount = hunk.lines.filter((line) => line.startsWith(' ') || line.startsWith('-')).length; + const newCount = hunk.lines.filter((line) => line.startsWith(' ') || line.startsWith('+')).length; + if (oldCount !== hunk.oldCount || newCount !== hunk.newCount) { + throw new Error(`Unified diff hunk counts do not match its header for ${expectedPath}`); + } + while (cursor.value < oldIndex) { + if (cursor.value >= baseLines.length) throw new Error(`Unified diff for ${expectedPath} ran past end of file`); + output.push(baseLines[cursor.value]!); + cursor.value += 1; + } + for (const line of hunk.lines) { + if (line.startsWith('\\')) continue; + const mark = line[0]; + const body = line.slice(1); + if (mark === ' ') { + if (baseLines[cursor.value] !== body) { + throw new Error(`Unified diff context mismatch for ${expectedPath} at line ${cursor.value + 1}`); + } + output.push(baseLines[cursor.value]!); + cursor.value += 1; + } else if (mark === '-') { + if (baseLines[cursor.value] !== body) { + throw new Error(`Unified diff deletion mismatch for ${expectedPath} at line ${cursor.value + 1}`); + } + cursor.value += 1; + } else if (mark === '+') { + output.push(body); + } else if (line === '') { + throw new Error(`Unified diff for ${expectedPath} has an unprefixed hunk line`); + } else { + throw new Error(`Unified diff for ${expectedPath} has unsupported hunk line`); + } + } +} + +export function applyUnifiedDiffToText(base: string, diff: string, expectedPath: string): string { + const normalizedDiff = normalizeUnifiedDiff(diff, expectedPath); + const baseLines = splitKeep(base); + const hunks = parseUnifiedDiffHunks(normalizedDiff.split('\n'), expectedPath); + const cursor = { value: 0 }; + const output: string[] = []; + for (const hunk of hunks) { + applyUnifiedDiffHunk(hunk, baseLines, output, cursor, expectedPath); + } + while (cursor.value < baseLines.length) { + output.push(baseLines[cursor.value]!); + cursor.value += 1; + } + if (base.endsWith('\n') || output.length === 0) return `${output.join('\n')}${output.length ? '\n' : ''}`; + return output.join('\n'); +} diff --git a/src/synthesis/code-change-plan.ts b/src/synthesis/code-change-plan.ts index f85f36cd..f1e17e63 100644 --- a/src/synthesis/code-change-plan.ts +++ b/src/synthesis/code-change-plan.ts @@ -1,1310 +1,41 @@ -import { randomUUID } from 'node:crypto'; -import { existsSync, promises as fs } from 'node:fs'; -import path from 'node:path'; -import { - createCodeChangePlanHash, - createCodeChangePlanId, - createCodeChangeSourcePatchHash, - createCodeChangeSourcePatchId, - sha256, - stableStringify, -} from '../core/id.js'; -import { ensureDir, pathExists, readJson, readText } from '../core/io.js'; -import { assertPathWithinRoot } from '../core/security.js'; -import { - assertCodeChangeAcceptance, - assertCodeChangePlanForAcceptance, - assertCodeChangePlans, - assertCodeChangePlansForReview, - assertConclusions, - assertGroundedGenerationMetadata, - assertIntentGraph, -} from '../core/schema.js'; -import type { - CodeChangeAcceptance, - CodeChangeCloseResult, - CodeChangeFile, - CodeChangeFileAction, - CodeChangePlan, - CodeChangeReviewPatch, - CodeChangeSourceApplyReceipt, - CodeChangeSourceEdit, - CodeChangeSourcePatch, - CodeChangeSourcePatchApproval, - CodeChangeSourcePatchSet, - Conclusion, - Diagnostic, - DiagnosticReport, - GroundedGenerationMetadata, - IntentGraph, - IntentRecord, - IntentTarget, - TodoPriority, - TodoProposal, -} from '../core/types.js'; -import { normalizeTarget } from '../core/target.js'; -import { diagnoseGraph } from '../graph/diagnostics.js'; -import { T2C_VERSION } from '../version.js'; -import { isUsefulCodeChangePath } from './code-change-path.js'; - export { isUsefulCodeChangePath } from './code-change-path.js'; -const IMPLEMENTATION_DIAGNOSTIC_CODES = new Set([ - 'PLANNED_NOT_IMPLEMENTED', - 'CHANGELOG_WITHOUT_IMPLEMENTATION', -]); - -export interface ProposeCodeChangePlansOptions { - graph: IntentGraph; - diagnostics: DiagnosticReport; - conclusions?: Conclusion[]; - proposals?: TodoProposal[]; - generatedAt?: string; - /** Limit how many plans are materialised from open diagnostics. Default 50. */ - maxPlans?: number; - /** - * Repository probe used to tell `create` from `modify`. Injected rather than - * read here so plan synthesis stays pure and deterministic; when omitted the - * plan cannot know and keeps the conservative `modify`. - * See {@link createRepositoryPathProbe}. - */ - pathExists?: (relativePath: string) => boolean; -} - -export interface ProposeCodeChangePlansResult { - schemaVersion: 't2c.code-change-plan-set/v1'; - plans: CodeChangePlan[]; - generatedAt: string; - graphFingerprint: string; - sourceDiagnosticCount: number; - generation: GroundedGenerationMetadata; -} - -export interface EvaluateCodeChangeAcceptanceOptions { - plan: CodeChangePlan; - /** Graph and diagnostics that the plan was grounded on. */ - before: { graph: IntentGraph; diagnostics: DiagnosticReport }; - /** Graph after an attempted implementation (re-extracted and re-linked). */ - afterGraph: IntentGraph; - /** Optional precomputed after diagnostics; derived when omitted. */ - afterDiagnostics?: DiagnosticReport; - evaluatedAt?: string; -} - -export interface CloseCodeChangesOptions { - plans: CodeChangePlan[]; - before: { graph: IntentGraph; diagnostics: DiagnosticReport }; - afterGraph: IntentGraph; - afterDiagnostics?: DiagnosticReport; - evaluatedAt?: string; -} - -/** - * Build grounded code-change plans from open implementation diagnostics. - * - * One plan is produced per diagnostic that can name at least one target path - * (from the diagnostic's records or a matching TODO proposal). The runtime - * never invents file paths and never marks work complete. - */ -export function proposeCodeChangePlans(options: ProposeCodeChangePlansOptions): ProposeCodeChangePlansResult { - assertIntentGraph(options.graph); - assertConclusions([], { graph: options.graph, diagnostics: options.diagnostics }); - const generatedAt = options.generatedAt ?? new Date().toISOString(); - if (Number.isNaN(Date.parse(generatedAt))) throw new Error('generatedAt must be an ISO date-time'); - const maxPlans = options.maxPlans ?? 50; - if (!Number.isInteger(maxPlans) || maxPlans < 1 || maxPlans > 500) { - throw new Error('maxPlans must be an integer between 1 and 500'); - } - const conclusions = options.conclusions ?? []; - const proposals = options.proposals ?? []; - const recordsById = new Map(options.graph.records.map((record) => [record.id, record])); - const proposalsByDiagnostic = indexProposalsByDiagnostic(proposals); - const conclusionsByDiagnostic = indexConclusionsByDiagnostic(conclusions); - - const candidates = options.diagnostics.diagnostics - .filter((diagnostic) => IMPLEMENTATION_DIAGNOSTIC_CODES.has(diagnostic.code)) - // A released CHANGELOG entry is an audit signal; an open TODO is an - // explicit request for work. With a bounded plan set, sorting only by - // content id allowed historical release notes to consume every slot and - // hide the repository's actual backlog from autonomous executors. - .sort((left, right) => implementationDiagnosticRank(left) - - implementationDiagnosticRank(right) || left.id.localeCompare(right.id)); - - const plans: CodeChangePlan[] = []; - for (const diagnostic of candidates) { - if (plans.length >= maxPlans) break; - const relatedRecords = diagnostic.recordIds - .map((id) => recordsById.get(id)) - .filter((record): record is IntentRecord => Boolean(record)); - if (!relatedRecords.length) continue; - - const matchingProposals = proposalsByDiagnostic.get(diagnostic.id) ?? []; - const matchingConclusions = conclusionsByDiagnostic.get(diagnostic.id) ?? []; - const target = collectTarget(relatedRecords, matchingProposals); - const changes = buildChanges(target, relatedRecords, diagnostic, options.pathExists); - if (!changes.length) continue; - - const generation = deterministicGeneration(generatedAt, 't2c/code-change-plan'); - const evidence = { - graphFingerprint: options.graph.fingerprint, - recordIds: uniqueSorted(relatedRecords.map((record) => record.id)), - diagnosticIds: [diagnostic.id], - conclusionIds: uniqueSorted(matchingConclusions.map((item) => item.id)), - proposalIds: uniqueSorted(matchingProposals.map((item) => item.id)), - }; - const semantic = { - title: titleFor(diagnostic, relatedRecords), - description: descriptionFor(diagnostic, relatedRecords, target), - priority: priorityFor(diagnostic), - target, - acceptanceCriteria: acceptanceCriteriaFor(diagnostic, target), - changes, - risk: riskFor(diagnostic, changes), - rollback: rollbackFor(changes), - evidence, - }; - const planHash = createCodeChangePlanHash(semantic); - const plan: CodeChangePlan = { - schemaVersion: 't2c.code-change-plan/v1', - id: createCodeChangePlanId(semantic), - planHash, - status: 'proposed', - createdAt: generatedAt, - ...semantic, - confidence: confidenceFor(diagnostic, matchingProposals), - generation, - }; - plans.push(plan); - } - - assertCodeChangePlans(plans, { - graph: options.graph, - diagnostics: options.diagnostics, - conclusions, - proposals, - }); - - return { - schemaVersion: 't2c.code-change-plan-set/v1', - plans, - generatedAt, - graphFingerprint: options.graph.fingerprint, - sourceDiagnosticCount: candidates.length, - generation: deterministicGeneration(generatedAt, 't2c/code-change-plan-set'), - }; -} - -/** - * Build the repository probe for {@link ProposeCodeChangePlansOptions.pathExists}. - * - * A path that escapes the analysed root is reported as existing, so an unusual - * value degrades to today's conservative `modify` instead of instructing an - * executor to create a file outside the repository. - */ -export function createRepositoryPathProbe(root: string): (relativePath: string) => boolean { - const base = path.resolve(root); - return (relativePath: string): boolean => { - const absolute = path.resolve(base, relativePath); - if (absolute !== base && !absolute.startsWith(base + path.sep)) return true; - return existsSync(absolute); - }; -} - -function implementationDiagnosticRank(diagnostic: Diagnostic): number { - return diagnostic.code === 'PLANNED_NOT_IMPLEMENTED' ? 0 : 1; -} - -/** - * Re-diagnose an after graph and decide whether the plan's targeted - * diagnostics cleared without introducing new blocking findings. - * - * Diagnostic IDs are content-bound, so a still-open finding on the same - * records keeps the same ID. Cleared findings simply disappear. - */ -export function evaluateCodeChangeAcceptance( - options: EvaluateCodeChangeAcceptanceOptions, -): CodeChangeAcceptance { - assertIntentGraph(options.before.graph); - assertIntentGraph(options.afterGraph); - assertConclusions([], options.before); - assertCodeChangePlanForAcceptance(options.plan, options.before); - - const afterDiagnostics = options.afterDiagnostics ?? diagnoseGraph( - options.afterGraph, - options.evaluatedAt ?? new Date().toISOString(), - ); - assertConclusions([], { graph: options.afterGraph, diagnostics: afterDiagnostics }); - - const beforeIds = new Set(options.before.diagnostics.diagnostics.map((item) => item.id)); - const afterById = new Map(afterDiagnostics.diagnostics.map((item) => [item.id, item])); - const afterIds = [...afterById.keys()].sort(); - const targeted = options.plan.evidence.diagnosticIds; - const clearedDiagnosticIds = targeted.filter((id) => !afterById.has(id)).sort(); - const remainingDiagnosticIds = targeted.filter((id) => afterById.has(id)).sort(); - const newBlockingDiagnosticIds = afterDiagnostics.diagnostics - .filter((item) => item.severity === 'blocking' && !beforeIds.has(item.id)) - .map((item) => item.id) - .sort(); - - const reasons: string[] = []; - if (remainingDiagnosticIds.length) { - reasons.push( - `Targeted diagnostics still open: ${remainingDiagnosticIds.join(', ')}.`, - ); - } else { - reasons.push('All targeted diagnostics cleared after re-analysis.'); - } - if (newBlockingDiagnosticIds.length) { - reasons.push( - `New blocking diagnostics appeared: ${newBlockingDiagnosticIds.join(', ')}.`, - ); - } else { - reasons.push('No new blocking diagnostics appeared.'); - } - - const accepted = remainingDiagnosticIds.length === 0 && newBlockingDiagnosticIds.length === 0; - if (accepted) { - reasons.push('Acceptance gate passed; human approval is still required before DONE.'); - } else { - reasons.push('Acceptance gate failed.'); - } - - const evaluatedAt = options.evaluatedAt ?? new Date().toISOString(); - const acceptance: CodeChangeAcceptance = { - schemaVersion: 't2c.code-change-acceptance/v1', - planId: options.plan.id, - planHash: options.plan.planHash, - beforeGraphFingerprint: options.before.graph.fingerprint, - afterGraphFingerprint: options.afterGraph.fingerprint, - beforeDiagnosticIds: [...beforeIds].sort(), - afterDiagnosticIds: afterIds, - clearedDiagnosticIds, - remainingDiagnosticIds, - newBlockingDiagnosticIds, - accepted, - reasons: uniqueSorted(reasons), - evaluatedAt, - generation: deterministicGeneration(evaluatedAt, 't2c/code-change-acceptance'), - }; - assertCodeChangeAcceptance(acceptance, { - plan: options.plan, - before: options.before, - after: { graph: options.afterGraph, diagnostics: afterDiagnostics }, - }); - return acceptance; -} - -/** Evaluate a plan set under one timestamp without applying changes or marking DONE. */ -export function closeCodeChanges(options: CloseCodeChangesOptions): CodeChangeCloseResult { - const evaluatedAt = options.evaluatedAt ?? new Date().toISOString(); - if (Number.isNaN(Date.parse(evaluatedAt))) throw new Error('evaluatedAt must be an ISO date-time'); - assertIntentGraph(options.before.graph); - assertIntentGraph(options.afterGraph); - assertConclusions([], options.before); - const afterDiagnostics = options.afterDiagnostics ?? diagnoseGraph(options.afterGraph, evaluatedAt); - assertConclusions([], { graph: options.afterGraph, diagnostics: afterDiagnostics }); - const planIds = options.plans.map((plan) => plan.id); - if (new Set(planIds).size !== planIds.length) throw new Error('Code change close plans must have unique ids'); - - const acceptances = options.plans.map((plan) => evaluateCodeChangeAcceptance({ - plan, - before: options.before, - afterGraph: options.afterGraph, - afterDiagnostics, - evaluatedAt, - })); - const acceptedCount = acceptances.filter((item) => item.accepted).length; - return { - schemaVersion: 't2c.code-change-close-result/v1', - evaluatedAt, - graphFingerprintBefore: options.before.graph.fingerprint, - graphFingerprintAfter: options.afterGraph.fingerprint, - planCount: options.plans.length, - acceptedCount, - rejectedCount: options.plans.length - acceptedCount, - allAccepted: options.plans.length > 0 && acceptedCount === options.plans.length, - acceptances, - generation: deterministicGeneration(evaluatedAt, 't2c/code-change-close-result'), - }; -} - -function indexProposalsByDiagnostic(proposals: TodoProposal[]): Map { - const index = new Map(); - for (const proposal of proposals) { - for (const diagnosticId of proposal.diagnosticIds) { - const list = index.get(diagnosticId) ?? []; - list.push(proposal); - index.set(diagnosticId, list); - } - } - return index; -} - -function indexConclusionsByDiagnostic(conclusions: Conclusion[]): Map { - const index = new Map(); - for (const conclusion of conclusions) { - for (const diagnosticId of conclusion.diagnosticIds) { - const list = index.get(diagnosticId) ?? []; - list.push(conclusion); - index.set(diagnosticId, list); - } - } - return index; -} - -function collectTarget(records: IntentRecord[], proposals: TodoProposal[]): IntentTarget { - const paths = new Set(); - const symbols = new Set(); - const tickets = new Set(); - const versions = new Set(); - for (const record of records) { - for (const path of record.statement.target.paths) paths.add(path); - for (const symbol of record.statement.target.symbols) symbols.add(symbol); - for (const ticket of record.statement.target.tickets) tickets.add(ticket); - for (const version of record.statement.target.versions) versions.add(version); - } - for (const proposal of proposals) { - for (const path of proposal.target.paths) paths.add(path); - for (const symbol of proposal.target.symbols) symbols.add(symbol); - for (const ticket of proposal.target.tickets) tickets.add(ticket); - for (const version of proposal.target.versions) versions.add(version); - } - return normalizeTarget({ - paths: [...paths].filter(isUsefulCodeChangePath), - symbols: [...symbols], - tickets: [...tickets], - versions: [...versions], - }); -} - -function buildChanges( - target: IntentTarget, - records: IntentRecord[], - diagnostic: Diagnostic, - pathExistsInRepository?: (relativePath: string) => boolean, -): CodeChangeFile[] { - const symbols = uniqueSorted(target.symbols); - // The diagnostic explains why evidence is missing; it is not necessarily an - // implementation instruction. Reusing its generic remediation here produced - // contradictory tickets such as “replace magic number 50” followed by - // “provide a missing function”. The lossless source declaration is the work - // to perform, while the diagnostic remains available in the plan evidence. - const sourceIntents = uniqueSorted(records.map((record) => record.statement.text)); - const rationale = sourceIntents.length - ? `Implement the source intent: ${sourceIntents.join(' | ')}` - : diagnostic.detail || `Address ${diagnostic.code}.`; - - if (target.paths.length) { - const changes: CodeChangeFile[] = []; - for (const declared of uniqueSorted(target.paths)) { - const normalized = declared.replace(/\\/g, '/'); - const exists = pathExistsInRepository?.(normalized); - // A path without a directory is shorthand that never said *where* the - // file belongs. Creating one at the repository root invents a location: - // measured across seven foreign repositories this proposed `__init__.py` - // beside 22 real ones, `pyproject.toml` beside 32, and files named after - // prose fragments such as `it.md`. The diagnostic still reports the gap; - // only the invented instruction is withheld. - if (exists === false && !normalized.includes('/')) continue; - // Documentation routinely plans files that do not exist yet (a target - // repository's `docs/ARCHITECTURE.md`). Telling an executor to modify - // them is an instruction it cannot follow, and `apply-source-patch` - // rejects a create edit whose target already exists, so the two actions - // must not be guessed. - const action: CodeChangeFileAction = exists === false ? 'create' : 'modify'; - changes.push({ path: normalized, action, symbols, rationale }); - } - return changes; - } - - // Without a path the plan cannot safely name a source file. Skip rather than invent. - return []; -} - -function titleFor(diagnostic: Diagnostic, records: IntentRecord[]): string { - const record = records[0]; - const object = record?.statement.object?.trim(); - // `inferObject` removes the verb selected by the action classifier. In a - // compound sentence a later high-precedence verb can win (`verify` before - // `implement`), leaving the original leading imperative inside `object` and - // a broken fragment after the removed verb. The source statement is the - // lossless title whenever that mismatch is visible. - if (object && startsWithImperative(object) && record?.statement.text.trim()) { - return record.statement.text.trim().replace(/[.!?]+$/, ''); - } - if (object) return `Implement ${object}`; - return diagnostic.title.trim() || `Resolve ${diagnostic.code}`; -} - -function startsWithImperative(value: string): boolean { - return /^(?:add|build|change|configure|create|delete|document|fix|implement|preserve|refactor|remove|test|update|validate|verify)\b/i.test(value) - || /^(?:dodać|dodac|naprawić|naprawic|przetestować|przetestowac|usunąć|usunac|utworzyć|utworzyc|wdrożyć|wdrozyc|zmienić|zmienic|zweryfikować|zweryfikowac)\b/i.test(value); -} - -function descriptionFor( - diagnostic: Diagnostic, - records: IntentRecord[], - target: IntentTarget, -): string { - const parts = [ - diagnostic.detail.trim(), - records[0] ? `Source intent: ${records[0].statement.text.trim()}` : '', - target.paths.length ? `Paths: ${target.paths.join(', ')}.` : '', - target.symbols.length ? `Symbols: ${target.symbols.join(', ')}.` : '', - target.tickets.length ? `Tickets: ${target.tickets.join(', ')}.` : '', - ].filter(Boolean); - return parts.join(' '); -} - -function acceptanceCriteriaFor(diagnostic: Diagnostic, target: IntentTarget): string[] { - const criteria = [ - `Re-run todo2code link+diagnose and clear diagnostic ${diagnostic.id} (${diagnostic.code}).`, - 'Do not introduce new blocking diagnostics.', - ]; - if (target.paths.length) { - criteria.push(`Touch only the declared paths: ${uniqueSorted(target.paths).join(', ')}.`); - } - if (target.symbols.length) { - criteria.push(`Provide AST evidence for symbols: ${uniqueSorted(target.symbols).join(', ')}.`); - } - return uniqueSorted(criteria); -} - -function priorityFor(diagnostic: Diagnostic): TodoPriority { - if (diagnostic.severity === 'blocking') return 'P0'; - if (diagnostic.severity === 'review_required') return 'P1'; - if (diagnostic.severity === 'warning') return 'P2'; - return 'P3'; -} - -function confidenceFor(diagnostic: Diagnostic, proposals: TodoProposal[]): number { - if (proposals.length) { - return Math.min(0.92, Math.max(...proposals.map((item) => item.confidence))); - } - if (diagnostic.severity === 'blocking') return 0.88; - if (diagnostic.severity === 'review_required') return 0.8; - return 0.72; -} - -function riskFor(diagnostic: Diagnostic, changes: CodeChangeFile[]): CodeChangePlan['risk'] { - const level = diagnostic.severity === 'blocking' ? 'high' - : diagnostic.severity === 'review_required' ? 'medium' - : 'low'; - const reasons = [ - `Derived from ${diagnostic.severity} diagnostic ${diagnostic.id}.`, - `Touches ${changes.length} declared ${changes.length === 1 ? 'path' : 'paths'}.`, - ]; - return { level, reasons: uniqueSorted(reasons) }; -} - -function rollbackFor(changes: CodeChangeFile[]): string { - return `Revert the proposed changes to ${uniqueSorted(changes.map((item) => item.path)).join(', ')} and re-run todo2code diagnostics.`; -} - -function deterministicGeneration(generatedAt: string, generator: string): GroundedGenerationMetadata { - return { - generator, - generatorVersion: '1', - runtimeVersion: T2C_VERSION, - generatedAt, - requestedMode: 'deterministic', - effectiveMode: 'deterministic', - degraded: false, - model: null, - provider: null, - responseId: null, - configurationFingerprint: sha256(stableStringify({ - generator, - generatorVersion: '1', - codes: [...IMPLEMENTATION_DIAGNOSTIC_CODES].sort(), - })), - reason: null, - }; -} - -function uniqueSorted(values: string[]): string[] { - return [...new Set(values.map((item) => item.trim()).filter(Boolean))].sort(); -} - -export interface CreateCodeChangeReviewOptions { - plans: CodeChangePlan[]; - graphFingerprint: string; - createdAt?: string; -} - -export interface CreatedCodeChangeReview { - markdown: string; - artifact: CodeChangeReviewPatch; -} - -/** - * Render a stable, reviewable Markdown brief for grounded code-change plans. - * - * This is not a source patch and is never applied to the tree. It exists so - * humans and agents share one hash-bound artifact that lists exact paths, - * acceptance criteria, evidence IDs, risk and rollback instructions. - */ -export function createCodeChangeReviewPatch( - options: CreateCodeChangeReviewOptions, -): CreatedCodeChangeReview { - if (typeof options.graphFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(options.graphFingerprint)) { - throw new Error('graphFingerprint must be a SHA-256 hex digest'); - } - assertCodeChangePlansForReview(options.plans, options.graphFingerprint); - const plans = [...options.plans].sort((left, right) => - priorityRank(left.priority) - priorityRank(right.priority) || left.id.localeCompare(right.id)); - const createdAt = options.createdAt ?? new Date().toISOString(); - if (Number.isNaN(Date.parse(createdAt))) throw new Error('createdAt must be an ISO date-time'); - const markdown = renderCodeChangeReviewMarkdown(plans, options.graphFingerprint); - const artifact: CodeChangeReviewPatch = { - schemaVersion: 't2c.code-change-review/v1', - createdAt, - graphFingerprint: options.graphFingerprint, - planIds: plans.map((plan) => plan.id), - planHashes: plans.map((plan) => plan.planHash), - renderedPatchHash: sha256(markdown), - generation: deterministicGeneration(createdAt, 't2c/code-change-review'), - }; - assertCodeChangeReviewPatch(artifact); - return { markdown, artifact }; -} - -export function renderCodeChangeReviewMarkdown( - plans: CodeChangePlan[], - graphFingerprint: string, -): string { - const lines = [ - '', - '# todo2code proposed code changes', - '', - 'This document is a grounded **review brief**, not an auto-applied source patch.', - 'Implement the listed paths in a normal branch, re-run the pipeline, then', - '`t2c evaluate-code-change`. Acceptance still requires human/CI approval before DONE.', - '', - `Graph fingerprint: \`${graphFingerprint}\``, - '', - ]; - if (!plans.length) { - lines.push('_No grounded code-change plans. Open diagnostics either cleared or lack repository paths._', ''); - return lines.join('\n'); - } - let currentPriority: CodeChangePlan['priority'] | null = null; - for (const plan of plans) { - if (plan.priority !== currentPriority) { - if (currentPriority !== null) lines.push(''); - currentPriority = plan.priority; - lines.push(`## ${plan.priority}`, ''); - } - lines.push(`### ${inline(plan.title)} (\`${plan.id}\`)`, ''); - lines.push(`- Plan hash: \`${plan.planHash}\``); - lines.push(`- Risk: **${plan.risk.level}** — ${plan.risk.reasons.map(inline).join('; ')}`); - lines.push(`- Confidence: ${plan.confidence.toFixed(2)}`); - lines.push(`- Description: ${inline(plan.description)}`); - lines.push('- Changes:'); - for (const change of plan.changes) { - const symbols = change.symbols.length ? ` symbols: ${change.symbols.map((item) => `\`${item}\``).join(', ')}` : ''; - lines.push(` - \`${change.action}\` \`${change.path}\`${symbols}`); - lines.push(` - ${inline(change.rationale)}`); - } - lines.push('- Acceptance criteria:'); - for (const criterion of plan.acceptanceCriteria) lines.push(` - [ ] ${inline(criterion)}`); - lines.push(`- Diagnostics: ${renderIds(plan.evidence.diagnosticIds)}`); - lines.push(`- Evidence records: ${renderIds(plan.evidence.recordIds)}`); - if (plan.evidence.proposalIds.length) lines.push(`- TODO proposals: ${renderIds(plan.evidence.proposalIds)}`); - if (plan.evidence.conclusionIds.length) lines.push(`- Conclusions: ${renderIds(plan.evidence.conclusionIds)}`); - lines.push(`- Rollback: ${inline(plan.rollback)}`); - lines.push(''); - } - lines.push('## After implementation', ''); - lines.push('1. Re-run `t2c pipeline` (or extract + link + diagnose) on the changed tree.'); - lines.push('2. `t2c evaluate-code-change --before-graph … --after-graph … --out acceptance.json`.'); - lines.push('3. Require `accepted=true` and human/CI review before marking work DONE.'); - lines.push(''); - return lines.join('\n'); -} - -export function assertCodeChangeReviewPatch(value: unknown): asserts value is CodeChangeReviewPatch { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('Code change review patch must be an object'); - } - const artifact = value as Record; - const required = [ - 'schemaVersion', 'createdAt', 'graphFingerprint', 'planIds', 'planHashes', - 'renderedPatchHash', 'generation', - ]; - for (const key of required) { - if (!(key in artifact)) throw new Error(`Code change review patch is missing: ${key}`); - } - if (artifact.schemaVersion !== 't2c.code-change-review/v1') { - throw new Error('Unsupported code change review schemaVersion'); - } - if (typeof artifact.createdAt !== 'string' || Number.isNaN(Date.parse(artifact.createdAt))) { - throw new Error('Code change review createdAt must be an ISO date-time'); - } - if (typeof artifact.graphFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(artifact.graphFingerprint)) { - throw new Error('Code change review graphFingerprint must be SHA-256'); - } - if (typeof artifact.renderedPatchHash !== 'string' || !/^[a-f0-9]{64}$/.test(artifact.renderedPatchHash)) { - throw new Error('Code change review renderedPatchHash must be SHA-256'); - } - if (!Array.isArray(artifact.planIds) || !artifact.planIds.every((id) => typeof id === 'string' && /^CPLAN-[a-f0-9]{20}$/.test(id))) { - throw new Error('Code change review planIds must be CPLAN ids'); - } - if (!Array.isArray(artifact.planHashes) || !artifact.planHashes.every((hash) => typeof hash === 'string' && /^[a-f0-9]{64}$/.test(hash))) { - throw new Error('Code change review planHashes must be SHA-256 digests'); - } - if (artifact.planIds.length !== artifact.planHashes.length) { - throw new Error('Code change review planIds and planHashes must have equal length'); - } - if (new Set(artifact.planIds as string[]).size !== (artifact.planIds as string[]).length) { - throw new Error('Code change review planIds must be unique'); - } - assertGroundedGenerationMetadata(artifact.generation, 'Code change review generation'); - const generation = artifact.generation as GroundedGenerationMetadata; - if (generation.generatedAt !== artifact.createdAt) { - throw new Error('Code change review generation.generatedAt must match createdAt'); - } - if (generation.generator !== 't2c/code-change-review') { - throw new Error('Code change review generation.generator must be t2c/code-change-review'); - } -} - -function priorityRank(priority: TodoPriority): number { - return ({ P0: 0, P1: 1, P2: 2, P3: 3 } as const)[priority]; -} - -function inline(value: string): string { - return value.replace(/\s+/g, ' ').trim(); -} - -function renderIds(ids: string[]): string { - return ids.length ? ids.map((id) => `\`${id}\``).join(', ') : '_none_'; -} - -export interface CreateCodeChangeSourcePatchOptions { - plan: CodeChangePlan; - /** Optional per-path unified diffs keyed by relative repository path. */ - unifiedDiffs?: Record; - createdAt?: string; -} - -/** - * Build a structured source-edit proposal from one grounded code-change plan. - * - * Deterministic by default: each planned file gets an imperative instruction. - * Callers may attach a unified diff per path; the runtime validates path headers - * and rejects traversal / host paths. Nothing is written to the working tree. - */ -export function createCodeChangeSourcePatch( - options: CreateCodeChangeSourcePatchOptions, -): CodeChangeSourcePatch { - const plan = options.plan; - const graphFingerprint = plan?.evidence?.graphFingerprint; - assertCodeChangePlansForReview( - [plan], - typeof graphFingerprint === 'string' ? graphFingerprint : '', - ); - const createdAt = options.createdAt ?? new Date().toISOString(); - if (Number.isNaN(Date.parse(createdAt))) throw new Error('createdAt must be an ISO date-time'); - const allowed = new Set(plan.target.paths.map((path) => path.replace(/\\/g, '/'))); - const diffs = options.unifiedDiffs ?? {}; - for (const path of Object.keys(diffs)) { - const normalized = path.replace(/\\/g, '/'); - if (!allowed.has(normalized)) { - throw new Error(`Unified diff path ${normalized} is not declared by plan ${plan.id}`); - } - } - const edits: CodeChangeSourceEdit[] = [...plan.changes] - .map((change) => { - const path = change.path.replace(/\\/g, '/'); - if (!allowed.has(path)) { - throw new Error(`Edit path ${path} is not present in plan target.paths`); - } - const rawDiff = diffs[path]; - const unifiedDiff = rawDiff === undefined ? null : normalizeUnifiedDiff(rawDiff, path); - return { - path, - action: change.action, - symbols: uniqueSorted(change.symbols), - instruction: instructionFor(change, plan), - unifiedDiff, - }; - }) - .sort((left, right) => left.path.localeCompare(right.path) || left.action.localeCompare(right.action)); - if (!edits.length) throw new Error(`Plan ${plan.id} has no editable paths`); - - const semantic = { - planId: plan.id, - planHash: plan.planHash, - graphFingerprint: plan.evidence.graphFingerprint, - diagnosticIds: uniqueSorted(plan.evidence.diagnosticIds), - recordIds: uniqueSorted(plan.evidence.recordIds), - edits, - acceptanceCriteria: uniqueSorted(plan.acceptanceCriteria), - }; - const patchHash = createCodeChangeSourcePatchHash(semantic); - const patch: CodeChangeSourcePatch = { - schemaVersion: 't2c.code-change-source-patch/v1', - id: createCodeChangeSourcePatchId(semantic), - patchHash, - status: 'proposed', - createdAt, - ...semantic, - generation: deterministicGeneration(createdAt, 't2c/code-change-source-patch'), - }; - assertCodeChangeSourcePatch(patch, plan); - return patch; -} - -export function createCodeChangeSourcePatchSet(options: { - plans: CodeChangePlan[]; - graphFingerprint: string; - unifiedDiffsByPlanId?: Record>; - generatedAt?: string; -}): CodeChangeSourcePatchSet { - if (typeof options.graphFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(options.graphFingerprint)) { - throw new Error('graphFingerprint must be a SHA-256 hex digest'); - } - assertCodeChangePlansForReview(options.plans, options.graphFingerprint); - const generatedAt = options.generatedAt ?? new Date().toISOString(); - const patches = [...options.plans] - .sort((left, right) => left.id.localeCompare(right.id)) - .map((plan) => createCodeChangeSourcePatch({ - plan, - createdAt: generatedAt, - ...(options.unifiedDiffsByPlanId?.[plan.id] - ? { unifiedDiffs: options.unifiedDiffsByPlanId[plan.id] } - : {}), - })); - const result: CodeChangeSourcePatchSet = { - schemaVersion: 't2c.code-change-source-patch-set/v1', - generatedAt, - graphFingerprint: options.graphFingerprint, - patches, - generation: deterministicGeneration(generatedAt, 't2c/code-change-source-patch-set'), - }; - assertCodeChangeSourcePatchSet(result, options.plans); - return result; -} - -export function assertCodeChangeSourcePatch( - value: unknown, - plan?: CodeChangePlan, -): asserts value is CodeChangeSourcePatch { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('Code change source patch must be an object'); - } - const patch = value as CodeChangeSourcePatch; - exactSourcePatchKeys(patch as unknown as Record, [ - 'schemaVersion', 'id', 'patchHash', 'status', 'createdAt', 'planId', 'planHash', - 'graphFingerprint', 'diagnosticIds', 'recordIds', 'edits', 'acceptanceCriteria', 'generation', - ], 'Source patch'); - if (patch.schemaVersion !== 't2c.code-change-source-patch/v1') { - throw new Error('Unsupported code change source patch schemaVersion'); - } - if (typeof patch.id !== 'string' || !/^SPATCH-[a-f0-9]{20}$/.test(patch.id)) { - throw new Error('Source patch id must match SPATCH-<20 hex>'); - } - if (typeof patch.patchHash !== 'string' || !/^[a-f0-9]{64}$/.test(patch.patchHash)) { - throw new Error('Source patch patchHash must be SHA-256'); - } - if (patch.status !== 'proposed') throw new Error('Source patch status must be proposed'); - if (typeof patch.createdAt !== 'string' || Number.isNaN(Date.parse(patch.createdAt))) { - throw new Error('Source patch createdAt must be an ISO date-time'); - } - if (typeof patch.planId !== 'string' || !/^CPLAN-[a-f0-9]{20}$/.test(patch.planId)) { - throw new Error('Source patch planId must match CPLAN-<20 hex>'); - } - if (typeof patch.planHash !== 'string' || !/^[a-f0-9]{64}$/.test(patch.planHash)) { - throw new Error('Source patch planHash must be SHA-256'); - } - if (typeof patch.graphFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(patch.graphFingerprint)) { - throw new Error('Source patch graphFingerprint must be SHA-256'); - } - if (!Array.isArray(patch.edits) || patch.edits.length === 0) { - throw new Error('Source patch edits must be a non-empty array'); - } - assertSourcePatchIds(patch.diagnosticIds, /^DIAG-[a-f0-9]{20}$/, 'diagnosticIds'); - assertSourcePatchIds(patch.recordIds, /^INT-[A-Z]+-[a-f0-9]{20}$/, 'recordIds'); - assertSourcePatchStrings(patch.acceptanceCriteria, 'acceptanceCriteria', false); - const paths = new Set(); - for (const edit of patch.edits) { - if (!edit || typeof edit !== 'object') throw new Error('Source patch edit must be an object'); - exactSourcePatchKeys(edit as unknown as Record, [ - 'path', 'action', 'symbols', 'instruction', 'unifiedDiff', - ], 'Source patch edit'); - const path = edit.path?.trim().replace(/\\/g, '/') ?? ''; - if (!path || path.startsWith('/') || path.split('/').includes('..')) { - throw new Error(`Source patch edit path is not a relative repository path: ${path}`); - } - if (!['create', 'modify', 'delete'].includes(edit.action)) { - throw new Error(`Source patch edit action is unsupported: ${String(edit.action)}`); - } - if (typeof edit.instruction !== 'string' || !edit.instruction.trim()) { - throw new Error('Source patch edit instruction must be non-blank'); - } - assertSourcePatchStrings(edit.symbols, `edits[${path}].symbols`, true); - if (edit.unifiedDiff !== null) { - if (typeof edit.unifiedDiff !== 'string') throw new Error('Source patch unifiedDiff must be string or null'); - normalizeUnifiedDiff(edit.unifiedDiff, path); - } - const key = `${path}::${edit.action}`; - if (paths.has(key)) throw new Error(`Duplicate source patch edit for ${path}`); - paths.add(key); - } - const expectedHash = createCodeChangeSourcePatchHash(patch); - if (patch.patchHash !== expectedHash) { - throw new Error(`Source patch patchHash does not match semantic content: expected ${expectedHash}`); - } - if (patch.id !== createCodeChangeSourcePatchId(patch)) { - throw new Error('Source patch id does not match semantic content'); - } - assertGroundedGenerationMetadata(patch.generation, 'Source patch generation'); - if (patch.generation.generatedAt !== patch.createdAt) { - throw new Error('Source patch generation.generatedAt must match createdAt'); - } - if (patch.generation.generator !== 't2c/code-change-source-patch') { - throw new Error('Source patch generation.generator must be t2c/code-change-source-patch'); - } - if (plan) { - if (patch.planId !== plan.id || patch.planHash !== plan.planHash) { - throw new Error('Source patch is not bound to the supplied plan'); - } - if (patch.graphFingerprint !== plan.evidence.graphFingerprint) { - throw new Error('Source patch graphFingerprint does not match the plan'); - } - const allowed = new Set(plan.target.paths.map((item) => item.replace(/\\/g, '/'))); - const expectedChanges = new Map(plan.changes.map((item) => [ - item.path.replace(/\\/g, '/'), item.action, - ])); - for (const edit of patch.edits) { - const editPath = edit.path.replace(/\\/g, '/'); - if (!allowed.has(editPath)) { - throw new Error(`Source patch path ${edit.path} is outside plan target.paths`); - } - if (expectedChanges.get(editPath) !== edit.action) { - throw new Error(`Source patch action for ${edit.path} does not match the plan`); - } - } - exactSourcePatchSet(patch.edits.map((item) => item.path.replace(/\\/g, '/')), [...expectedChanges.keys()], 'edit paths'); - exactSourcePatchSet(patch.diagnosticIds, plan.evidence.diagnosticIds, 'diagnosticIds'); - exactSourcePatchSet(patch.recordIds, plan.evidence.recordIds, 'recordIds'); - exactSourcePatchSet(patch.acceptanceCriteria, plan.acceptanceCriteria, 'acceptanceCriteria'); - } -} - -export function assertCodeChangeSourcePatchSet( - value: unknown, - plans?: CodeChangePlan[], -): asserts value is CodeChangeSourcePatchSet { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('Code change source patch set must be an object'); - } - const set = value as CodeChangeSourcePatchSet; - exactSourcePatchKeys(set as unknown as Record, [ - 'schemaVersion', 'generatedAt', 'graphFingerprint', 'patches', 'generation', - ], 'Source patch set'); - if (set.schemaVersion !== 't2c.code-change-source-patch-set/v1') { - throw new Error('Unsupported code change source patch set schemaVersion'); - } - if (typeof set.generatedAt !== 'string' || Number.isNaN(Date.parse(set.generatedAt))) { - throw new Error('Source patch set generatedAt must be an ISO date-time'); - } - if (typeof set.graphFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(set.graphFingerprint)) { - throw new Error('Source patch set graphFingerprint must be SHA-256'); - } - if (!Array.isArray(set.patches)) throw new Error('Source patch set patches must be an array'); - const plansById = new Map((plans ?? []).map((plan) => [plan.id, plan])); - const patchIds = new Set(); - for (const patch of set.patches) { - assertCodeChangeSourcePatch(patch, plans ? plansById.get(patch.planId) : undefined); - if (patch.graphFingerprint !== set.graphFingerprint) { - throw new Error(`Source patch ${patch.id} graphFingerprint does not match its set`); - } - if (patchIds.has(patch.id)) throw new Error(`Duplicate source patch id: ${patch.id}`); - patchIds.add(patch.id); - } - if (plans) exactSourcePatchSet(set.patches.map((patch) => patch.planId), plans.map((plan) => plan.id), 'planIds'); - assertGroundedGenerationMetadata(set.generation, 'Source patch set generation'); - if (set.generation.generatedAt !== set.generatedAt) { - throw new Error('Source patch set generation.generatedAt must match generatedAt'); - } - if (set.generation.generator !== 't2c/code-change-source-patch-set') { - throw new Error('Source patch set generation.generator must be t2c/code-change-source-patch-set'); - } -} - -function exactSourcePatchKeys(value: Record, expected: string[], name: string): void { - const actual = Object.keys(value).sort(); - const wanted = [...expected].sort(); - if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { - throw new Error(`${name} keys must be exactly: ${wanted.join(', ')}`); - } -} - -function assertSourcePatchIds(value: unknown, pattern: RegExp, name: string): asserts value is string[] { - if (!Array.isArray(value) || value.length === 0 - || value.some((item) => typeof item !== 'string' || !pattern.test(item))) { - throw new Error(`Source patch ${name} must be a non-empty array of valid IDs`); - } - if (new Set(value).size !== value.length) throw new Error(`Source patch ${name} must be unique`); -} - -function assertSourcePatchStrings(value: unknown, name: string, emptyAllowed: boolean): asserts value is string[] { - if (!Array.isArray(value) || (!emptyAllowed && value.length === 0) - || value.some((item) => typeof item !== 'string' || !item.trim())) { - throw new Error(`Source patch ${name} must contain ${emptyAllowed ? 'only ' : ''}non-blank strings`); - } - if (new Set(value).size !== value.length) throw new Error(`Source patch ${name} must be unique`); -} - -function exactSourcePatchSet(actual: string[], expected: string[], name: string): void { - const left = [...new Set(actual)].sort(); - const right = [...new Set(expected)].sort(); - if (left.length !== right.length || left.some((item, index) => item !== right[index])) { - throw new Error(`Source patch ${name} do not match the plan`); - } -} - -function instructionFor(change: CodeChangeFile, plan: CodeChangePlan): string { - const symbols = change.symbols.length - ? ` Focus on symbols: ${change.symbols.join(', ')}.` - : ''; - const criteria = plan.acceptanceCriteria.length - ? ` Acceptance: ${plan.acceptanceCriteria.join(' ')}` - : ''; - return `${change.action} \`${change.path}\`. ${change.rationale.trim()}.${symbols}${criteria}`.replace(/\s+/g, ' ').trim(); -} - -/** - * Validate a single-file unified diff body. - * Accepts optional `--- a/path` / `+++ b/path` headers and rejects foreign paths. - */ -function normalizeUnifiedDiff(diff: string, expectedPath: string): string { - const normalized = diff.replace(/\r\n/g, '\n'); - if (!normalized.trim()) throw new Error(`Unified diff for ${expectedPath} is empty`); - if (normalized.includes('\0')) throw new Error(`Unified diff for ${expectedPath} contains NUL bytes`); - // Lightweight secret heuristic — refuse obvious credential dumps in proposed diffs. - if (/(?:api[_-]?key|secret|password|private[_-]?key)\s*[:=]\s*['"]?[^'"\s]{8,}/i.test(normalized)) { - throw new Error(`Unified diff for ${expectedPath} appears to contain a secret assignment`); - } - const headers = [...normalized.matchAll(/^(?:---|\+\+\+)\s+(?:[ab]\/)?(.+)$/gm)].map((match) => match[1]!.trim()); - for (const header of headers) { - if (header === '/dev/null') continue; - const path = header.replace(/\\/g, '/'); - if (path.startsWith('/') || path.split('/').includes('..')) { - throw new Error(`Unified diff for ${expectedPath} uses a non-repository path header: ${path}`); - } - if (path !== expectedPath && path !== `a/${expectedPath}` && path !== `b/${expectedPath}`) { - // Headers may include timestamps after a tab; strip them. - const bare = path.split('\t')[0] ?? path; - const stripped = bare.replace(/^[ab]\//, ''); - if (stripped !== expectedPath) { - throw new Error(`Unified diff for ${expectedPath} references foreign path: ${path}`); - } - } - } - return normalized; -} - -export interface ApplyCodeChangeSourcePatchOptions { - root: string; - patch: CodeChangeSourcePatch; - approval: CodeChangeSourcePatchApproval; - receiptPath: string; - now?: Date; -} - -export interface ApplyCodeChangeSourcePatchResult { - applied: boolean; - idempotent: boolean; - receipt: CodeChangeSourceApplyReceipt; -} - -/** - * Apply a fully-diffed source patch after explicit hash approval. - * - * Instruction-only edits (null unifiedDiff) are rejected. Paths must stay - * relative and inside `root`. Re-applying with an existing matching receipt is - * idempotent. - */ -export async function applyCodeChangeSourcePatch( - options: ApplyCodeChangeSourcePatchOptions, -): Promise { - assertCodeChangeSourcePatch(options.patch); - if (!options.approval?.actor?.trim()) throw new Error('Explicit source patch approval actor is required'); - if (options.approval.patchHash !== options.patch.patchHash) { - throw new Error('Source patch approval hash does not match the patch'); - } - for (const edit of options.patch.edits) { - if (edit.unifiedDiff === null) { - throw new Error(`Source patch edit ${edit.path} has no unifiedDiff and cannot be applied`); - } - } - - const root = path.resolve(options.root); - const receiptPath = await assertPathWithinRoot(root, path.resolve(options.receiptPath)); - const lockPath = `${receiptPath}.t2c-apply.lock`; - await ensureDir(path.dirname(receiptPath)); - let lock: Awaited> | null = null; - try { - lock = await fs.open(lockPath, 'wx'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'EEXIST') { - throw new Error('Another source patch apply operation is in progress'); - } - throw error; - } - - try { - if (await pathExists(receiptPath)) { - const existing = await readJson(receiptPath, 1024 * 1024); - await assertExistingSourceReceipt(existing, options.patch, root); - return { applied: false, idempotent: true, receipt: existing }; - } - - const prepared: PreparedSourceEdit[] = []; - for (const edit of options.patch.edits) { - const relative = edit.path.replace(/\\/g, '/'); - const absolute = await assertPathWithinRoot(root, path.resolve(root, relative)); - if (absolute === receiptPath) { - throw new Error(`Source patch target collides with its receipt path: ${relative}`); - } - const exists = await pathExists(absolute); - if (exists && (await fs.lstat(absolute)).isSymbolicLink()) { - throw new Error(`Refusing to apply through a symlink: ${relative}`); - } - if (edit.action === 'create' && exists) throw new Error(`Source patch create target already exists: ${relative}`); - if (edit.action === 'delete' && !exists) throw new Error(`Source patch delete target does not exist: ${relative}`); - if (edit.action === 'modify' && !exists) { - const fromEmpty = /(?:^|\n)---\s+\/dev\/null(?:\n|$)/.test(edit.unifiedDiff!) - || /(?:^|\n)@@\s+-0(?:,0)?\s+\+/.test(edit.unifiedDiff!); - if (!fromEmpty) throw new Error(`Source patch modify target does not exist: ${relative}`); - } - const before = exists ? await readText(absolute, 16 * 1024 * 1024) : ''; - const after = applyUnifiedDiffToText(before, edit.unifiedDiff!, relative); - if (edit.action === 'delete' && after !== '') { - throw new Error(`Source patch delete diff must remove the complete file: ${relative}`); - } - prepared.push({ relative, absolute, action: edit.action, before, after, existed: exists }); - } - - const changed: PreparedSourceEdit[] = []; - try { - for (const edit of prepared) { - if (edit.action === 'delete') await fs.unlink(edit.absolute); - else await atomicWriteRaw(edit.absolute, edit.after); - changed.push(edit); - } - const now = (options.now ?? new Date()).toISOString(); - const fileHashesAfter = Object.fromEntries(prepared - .map((edit): [string, string] => [edit.relative, sha256(edit.after)]) - .sort(([left], [right]) => left.localeCompare(right))); - const receipt: CodeChangeSourceApplyReceipt = { - schemaVersion: 't2c.code-change-source-apply-receipt/v1', - patchId: options.patch.id, - patchHash: options.patch.patchHash, - planId: options.patch.planId, - approvedBy: options.approval.actor.trim(), - approvedAt: now, - appliedAt: now, - appliedPaths: prepared.map((edit) => edit.relative).sort(), - fileHashesAfter, - generation: deterministicGeneration(now, 't2c/code-change-source-apply'), - }; - assertSourceApplyReceipt(receipt, options.patch); - // The receipt is part of the transaction: without it a retry could apply - // the same approved patch again. Roll files back if persisting it fails. - await atomicWriteRaw(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); - return { applied: true, idempotent: false, receipt }; - } catch (error) { - const rollbackErrors: string[] = []; - for (const edit of [...changed].reverse()) { - try { - if (edit.existed) await atomicWriteRaw(edit.absolute, edit.before); - else await fs.unlink(edit.absolute).catch((failure: NodeJS.ErrnoException) => { - if (failure.code !== 'ENOENT') throw failure; - }); - } catch (rollbackError) { - rollbackErrors.push(`${edit.relative}: ${String(rollbackError)}`); - } - } - if (rollbackErrors.length) { - throw new Error(`Source patch apply failed (${String(error)}); rollback also failed: ${rollbackErrors.join('; ')}`); - } - throw error; - } - } finally { - await lock.close(); - await fs.unlink(lockPath).catch(() => undefined); - } -} - -interface PreparedSourceEdit { - relative: string; - absolute: string; - action: CodeChangeFileAction; - before: string; - after: string; - existed: boolean; -} - -async function assertExistingSourceReceipt( - receipt: CodeChangeSourceApplyReceipt, - patch: CodeChangeSourcePatch, - root: string, -): Promise { - try { - assertSourceApplyReceipt(receipt, patch); - } catch { - throw new Error('A different or invalid source patch receipt already exists at the receipt path'); - } - for (const edit of patch.edits) { - const relative = edit.path.replace(/\\/g, '/'); - const absolute = await assertPathWithinRoot(root, path.resolve(root, relative)); - const exists = await pathExists(absolute); - if (edit.action === 'delete') { - if (exists) throw new Error(`Applied source patch state changed after receipt: ${relative}`); - continue; - } - if (!exists || (await fs.lstat(absolute)).isSymbolicLink()) { - throw new Error(`Applied source patch state changed after receipt: ${relative}`); - } - const current = await readText(absolute, 16 * 1024 * 1024); - if (receipt.fileHashesAfter[relative] !== sha256(current)) { - throw new Error(`Applied source patch state changed after receipt: ${relative}`); - } - } -} - -function assertSourceApplyReceipt(receipt: CodeChangeSourceApplyReceipt, patch: CodeChangeSourcePatch): void { - exactSourcePatchKeys(receipt as unknown as Record, [ - 'schemaVersion', 'patchId', 'patchHash', 'planId', 'approvedBy', 'approvedAt', - 'appliedAt', 'appliedPaths', 'fileHashesAfter', 'generation', - ], 'Code change source apply receipt'); - if (receipt.schemaVersion !== 't2c.code-change-source-apply-receipt/v1' - || receipt.patchId !== patch.id || receipt.patchHash !== patch.patchHash || receipt.planId !== patch.planId) { - throw new Error('Code change source apply receipt does not match its patch'); - } - if (!receipt.approvedBy.trim()) throw new Error('Code change source apply receipt approvedBy is required'); - if (!Number.isFinite(Date.parse(receipt.approvedAt)) || !Number.isFinite(Date.parse(receipt.appliedAt))) { - throw new Error('Code change source apply receipt timestamps must be ISO date-times'); - } - const expectedPaths = patch.edits.map((edit) => edit.path).sort(); - exactSourcePatchSet(receipt.appliedPaths, expectedPaths, 'receipt appliedPaths'); - const hashPaths = Object.keys(receipt.fileHashesAfter).sort(); - exactSourcePatchSet(hashPaths, expectedPaths, 'receipt fileHashesAfter paths'); - if (Object.values(receipt.fileHashesAfter).some((value) => !/^[a-f0-9]{64}$/.test(value))) { - throw new Error('Code change source apply receipt file hashes must be SHA-256'); - } - assertGroundedGenerationMetadata(receipt.generation, 'Code change source apply receipt generation'); - if (receipt.generation.generatedAt !== receipt.appliedAt - || receipt.generation.generator !== 't2c/code-change-source-apply') { - throw new Error('Code change source apply receipt generation does not match the apply operation'); - } -} - -async function atomicWriteRaw(target: string, content: string): Promise { - await ensureDir(path.dirname(target)); - const temporary = `${target}.tmp-${process.pid}-${randomUUID()}`; - try { - await fs.writeFile(temporary, content, 'utf8'); - await fs.rename(temporary, target); - } finally { - await fs.unlink(temporary).catch(() => undefined); - } -} - -/** - * Apply a single-file unified diff to a text buffer. - * Supports standard hunks with space/+/− prefixes. Throws on context mismatch. - */ -export function applyUnifiedDiffToText(base: string, diff: string, expectedPath: string): string { - const normalizedDiff = normalizeUnifiedDiff(diff, expectedPath); - const baseLines = splitKeep(base); - const diffLines = normalizedDiff.split('\n'); - // Drop trailing empty element only if the original split introduced it - // without a final newline — normalize by working on lines as split. - const hunks: Array<{ oldStart: number; oldCount: number; newCount: number; lines: string[] }> = []; - let current: { oldStart: number; oldCount: number; newCount: number; lines: string[] } | null = null; - for (const line of diffLines) { - if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('diff ') || line.startsWith('index ')) { - continue; - } - const header = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line); - if (header) { - if (current) hunks.push(current); - current = { - oldStart: Number(header[1]), - oldCount: header[2] === undefined ? 1 : Number(header[2]), - newCount: header[4] === undefined ? 1 : Number(header[4]), - lines: [], - }; - continue; - } - if (!current) { - if (line === '') continue; - throw new Error(`Unified diff for ${expectedPath} has content outside hunks`); - } - // Blank lines without a unified-diff prefix separate hunks in some emitters. - if (line === '') continue; - current.lines.push(line); - } - if (current) hunks.push(current); - if (!hunks.length) throw new Error(`Unified diff for ${expectedPath} contains no hunks`); - - let cursor = 0; - const output: string[] = []; - for (const hunk of hunks) { - const oldIndex = Math.max(0, hunk.oldStart - 1); - if (oldIndex < cursor) throw new Error(`Unified diff for ${expectedPath} has overlapping or unordered hunks`); - const oldCount = hunk.lines.filter((line) => line.startsWith(' ') || line.startsWith('-')).length; - const newCount = hunk.lines.filter((line) => line.startsWith(' ') || line.startsWith('+')).length; - if (oldCount !== hunk.oldCount || newCount !== hunk.newCount) { - throw new Error(`Unified diff hunk counts do not match its header for ${expectedPath}`); - } - while (cursor < oldIndex) { - if (cursor >= baseLines.length) throw new Error(`Unified diff for ${expectedPath} ran past end of file`); - output.push(baseLines[cursor]!); - cursor += 1; - } - for (const line of hunk.lines) { - if (line.startsWith('\\')) continue; // "\ No newline at end of file" - const mark = line[0]; - const body = line.slice(1); - if (mark === ' ') { - if (baseLines[cursor] !== body) { - throw new Error(`Unified diff context mismatch for ${expectedPath} at line ${cursor + 1}`); - } - output.push(baseLines[cursor]!); - cursor += 1; - } else if (mark === '-') { - if (baseLines[cursor] !== body) { - throw new Error(`Unified diff deletion mismatch for ${expectedPath} at line ${cursor + 1}`); - } - cursor += 1; - } else if (mark === '+') { - output.push(body); - } else if (line === '') { - // empty line inside hunk without prefix is invalid in strict unified diffs - throw new Error(`Unified diff for ${expectedPath} has an unprefixed hunk line`); - } else { - throw new Error(`Unified diff for ${expectedPath} has unsupported hunk line`); - } - } - } - while (cursor < baseLines.length) { - output.push(baseLines[cursor]!); - cursor += 1; - } - // Reconstruct text. Files without a trailing newline end without an empty last segment. - if (base.endsWith('\n') || output.length === 0) return `${output.join('\n')}${output.length ? '\n' : ''}`; - return output.join('\n'); -} - -function splitKeep(text: string): string[] { - if (text === '') return []; - const lines = text.split('\n'); - if (text.endsWith('\n')) lines.pop(); - return lines; -} +export type { + ApplyCodeChangeSourcePatchOptions, + ApplyCodeChangeSourcePatchResult, + CloseCodeChangesOptions, + CreateCodeChangeReviewOptions, + CreateCodeChangeSourcePatchOptions, + CreatedCodeChangeReview, + EvaluateCodeChangeAcceptanceOptions, + ProposeCodeChangePlansOptions, + ProposeCodeChangePlansResult, +} from './code-change-plan-types.js'; + +export { + proposeCodeChangePlans, + createRepositoryPathProbe, +} from './code-change-plan-propose.js'; + +export { + evaluateCodeChangeAcceptance, + closeCodeChanges, +} from './code-change-plan-acceptance.js'; + +export { + createCodeChangeReviewPatch, + renderCodeChangeReviewMarkdown, + assertCodeChangeReviewPatch, +} from './code-change-plan-review.js'; + +export { + createCodeChangeSourcePatch, + createCodeChangeSourcePatchSet, + assertCodeChangeSourcePatch, + assertCodeChangeSourcePatchSet, +} from './code-change-plan-source-patch.js'; + +export { + applyCodeChangeSourcePatch, + applyUnifiedDiffToText, +} from './code-change-plan-apply.js'; diff --git a/src/tf/classifier.ts b/src/tf/classifier.ts index 46c5f314..d1e22d07 100644 --- a/src/tf/classifier.ts +++ b/src/tf/classifier.ts @@ -66,6 +66,29 @@ function vectorize(text: string, vocabulary: Record): number[] { return values; } +async function classifyWithModel( + text: string, + loaded: NonNullable, + fallback: IntentAction, +): Promise<{ action: IntentAction; basis: string; confidence: number }> { + const vector = vectorize(text, loaded.assets.vocabulary); + const input = loaded.tf.tensor2d([vector], [1, vector.length]); + const predictionValue = loaded.model.predict(input); + const prediction = Array.isArray(predictionValue) ? predictionValue[0] : predictionValue; + if (!prediction) throw new Error('TensorFlow model returned no prediction'); + const probabilities = Array.from(await prediction.data()); + prediction.dispose?.(); + let bestIndex = 0; + for (let index = 1; index < probabilities.length; index += 1) { + if ((probabilities[index] ?? 0) > (probabilities[bestIndex] ?? 0)) bestIndex = index; + } + const action = loaded.assets.labels[bestIndex] ?? fallback; + const confidence = Math.max(0, Math.min(1, probabilities[bestIndex] ?? 0)); + return confidence >= 0.55 + ? { action, basis: 'tensorflow_action_classifier', confidence } + : { action: fallback, basis: 'heuristic_fallback_after_tensorflow', confidence: Math.max(0.55, confidence) }; +} + export async function classifyAction(text: string, config: T2CConfig): Promise<{ action: IntentAction; basis: string; confidence: number }> { const fallback = classifyActionHeuristically(text); if (!config.enableTensorFlow || !config.tensorflowModelPath) { @@ -74,22 +97,7 @@ export async function classifyAction(text: string, config: T2CConfig): Promise<{ try { const loaded = await loadClassifier(config); if (!loaded) return { action: fallback, basis: 'heuristic_action_dictionary', confidence: 0.7 }; - const vector = vectorize(text, loaded.assets.vocabulary); - const input = loaded.tf.tensor2d([vector], [1, vector.length]); - const predictionValue = loaded.model.predict(input); - const prediction = Array.isArray(predictionValue) ? predictionValue[0] : predictionValue; - if (!prediction) throw new Error('TensorFlow model returned no prediction'); - const probabilities = Array.from(await prediction.data()); - prediction.dispose?.(); - let bestIndex = 0; - for (let index = 1; index < probabilities.length; index += 1) { - if ((probabilities[index] ?? 0) > (probabilities[bestIndex] ?? 0)) bestIndex = index; - } - const action = loaded.assets.labels[bestIndex] ?? fallback; - const confidence = Math.max(0, Math.min(1, probabilities[bestIndex] ?? 0)); - return confidence >= 0.55 - ? { action, basis: 'tensorflow_action_classifier', confidence } - : { action: fallback, basis: 'heuristic_fallback_after_tensorflow', confidence: Math.max(0.55, confidence) }; + return await classifyWithModel(text, loaded, fallback); } catch (error) { return { action: fallback, basis: `heuristic_fallback:${error instanceof Error ? error.message : String(error)}`, confidence: 0.6 }; } diff --git a/src/watch/watcher.ts b/src/watch/watcher.ts index d4fb4937..bc04e78c 100644 --- a/src/watch/watcher.ts +++ b/src/watch/watcher.ts @@ -144,7 +144,20 @@ export interface WatchOptions { const DEFAULT_MIN_INTERVAL_MS = 60_000; const DEFAULT_SCAN_INTERVAL_MS = 2_000; -export async function watchRepository(options: WatchOptions, config: T2CConfig): Promise { +interface WatchRuntime { + root: string; + minIntervalMs: number; + scanIntervalMs: number; + emit: (event: WatchEvent) => void; + now: () => number; + sleep: (ms: number, signal?: AbortSignal) => Promise; + signal: AbortSignal | undefined; + matcher: IgnoreMatcher; + runReport: (reason: string) => Promise; + scanOptions: ScanOptions; +} + +async function createWatchRuntime(options: WatchOptions, config: T2CConfig): Promise { const root = path.resolve(options.root); const minIntervalMs = Math.max(0, options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS); const scanIntervalMs = Math.max(50, options.scanIntervalMs ?? DEFAULT_SCAN_INTERVAL_MS); @@ -160,6 +173,14 @@ export async function watchRepository(options: WatchOptions, config: T2CConfig): }); const scanOptions: ScanOptions = { matcher, ...(options.maxFiles === undefined ? {} : { maxFiles: options.maxFiles }) }; + return { root, minIntervalMs, scanIntervalMs, emit, now, sleep, signal, matcher, runReport, scanOptions }; +} + +export async function watchRepository(options: WatchOptions, config: T2CConfig): Promise { + const runtime = await createWatchRuntime(options, config); + const { + root, minIntervalMs, scanIntervalMs, emit, now, sleep, signal, matcher, runReport, scanOptions, + } = runtime; let snapshot = await scanTree(root, scanOptions); emit({ type: 'ready', root, files: snapshot.size, sources: matcher.sources }); diff --git a/testql-scenarios/generated-cli-tests.testql.toon.yaml b/testql-scenarios/generated-cli-tests.testql.toon.yaml new file mode 100644 index 00000000..30ae77a9 --- /dev/null +++ b/testql-scenarios/generated-cli-tests.testql.toon.yaml @@ -0,0 +1,20 @@ +# SCENARIO: CLI Command Tests +# TYPE: cli +# GENERATED: true + +CONFIG[2]{key, value}: + cli_command, python -m todo2code + timeout_ms, 10000 + +# Test 1: CLI help command +SHELL "python -m todo2code --help" 5000 +ASSERT_EXIT_CODE 0 +ASSERT_STDOUT_CONTAINS "usage" + +# Test 2: CLI version command +SHELL "python -m todo2code --version" 5000 +ASSERT_EXIT_CODE 0 + +# Test 3: CLI main workflow (dry-run) +SHELL "python -m todo2code --help" 10000 +ASSERT_EXIT_CODE 0 \ No newline at end of file