codd-dev 1.22.0__tar.gz → 1.23.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {codd_dev-1.22.0 → codd_dev-1.23.0}/PKG-INFO +32 -5
- {codd_dev-1.22.0 → codd_dev-1.23.0}/README.md +30 -4
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/__init__.py +1 -1
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/cli.py +118 -18
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/coherence_engine.py +0 -1
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/coverage_metrics.py +2 -18
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/builder.py +214 -1
- codd_dev-1.23.0/codd/dag/defaults/test_frameworks.yaml +7 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/defaults/web.yaml +10 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/deployer.py +1 -120
- codd_dev-1.23.0/codd/hooks/recipes/claude_settings_example.json +15 -0
- codd_dev-1.23.0/codd/hooks/recipes/codex_hook.sh +31 -0
- codd_dev-1.23.0/codd/hooks/recipes/git_post_commit.sh +15 -0
- codd_dev-1.23.0/codd/hooks/recipes/git_pre_commit.sh +15 -0
- codd_dev-1.23.0/codd/watch/__init__.py +1 -0
- codd_dev-1.23.0/codd/watch/events.py +43 -0
- codd_dev-1.23.0/codd/watch/propagation_log.py +40 -0
- codd_dev-1.23.0/codd/watch/propagation_pipeline.py +233 -0
- codd_dev-1.23.0/codd/watch/test_runner.py +187 -0
- codd_dev-1.23.0/codd/watch/watcher.py +112 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/pyproject.toml +4 -1
- codd_dev-1.22.0/codd/drift_linkers/__init__.py +0 -46
- codd_dev-1.22.0/codd/drift_linkers/api.py +0 -484
- codd_dev-1.22.0/codd/drift_linkers/defaults/cli.yaml +0 -1
- codd_dev-1.22.0/codd/drift_linkers/defaults/iot.yaml +0 -1
- codd_dev-1.22.0/codd/drift_linkers/defaults/mobile.yaml +0 -2
- codd_dev-1.22.0/codd/drift_linkers/defaults/web.yaml +0 -8
- codd_dev-1.22.0/codd/drift_linkers/schema.py +0 -262
- codd_dev-1.22.0/codd/drift_linkers/screen_flow.py +0 -171
- {codd_dev-1.22.0 → codd_dev-1.23.0}/.gitignore +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/LICENSE +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/__main__.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/_git_helper.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/ask_user_question_adapter.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/assembler.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/bridge.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/clustering.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/coherence_adapters.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/config.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/contracts.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/coverage_auditor.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/__init__.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/checks/__init__.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/checks/depends_on_consistency.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/checks/edge_validity.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/checks/node_completeness.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/checks/task_completion.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/checks/transitive_closure.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/defaults/cli.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/defaults/iot.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/defaults/mobile.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/extractor.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/runner.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/defaults.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/deploy_targets/__init__.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/deploy_targets/app_service.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/deploy_targets/base.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/deploy_targets/docker_compose.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/design_md.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/drift.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/e2e_extractor.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/e2e_generator.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/e2e_runner.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/env_refs.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/extract_ai.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/extractor.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/fixer.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/fixup_drift.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/fixup_drift_strategies/__init__.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/fixup_drift_strategies/design_token_drift.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/fixup_drift_strategies/lexicon_violation.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/fixup_drift_strategies/url_drift.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/generator.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/graph.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/hitl_session.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/hooks/__init__.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/hooks/pre-commit +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/implementer.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/inheritance.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/knowledge_fetcher.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/lexicon.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/mcp_server.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/measure.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/parsing.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/planner.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/policy.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/preflight/__init__.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/preflight/defaults/cli.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/preflight/defaults/iot.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/preflight/defaults/mobile.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/preflight/defaults/web.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/propagate.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/propagator.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/registry.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/repair_slice.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/require.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/require_plugins.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/require_propagate.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/required_artifacts/defaults/cli.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/required_artifacts/defaults/iot.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/required_artifacts/defaults/mobile.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/required_artifacts/defaults/web.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/required_artifacts_deriver.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/requirement_completeness/defaults/cli.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/requirement_completeness/defaults/iot.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/requirement_completeness/defaults/mobile.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/requirement_completeness/defaults/web.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/requirement_completeness_auditor.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/restore.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/routes_extractor.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/scanner.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/schema_refs.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/screen_flow_validator.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/screen_transition_extractor.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/screen_transitions/defaults.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/synth.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/codd.yaml.tmpl +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/conventions.yaml.tmpl +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/data_dependencies.yaml.tmpl +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/doc_links.yaml.tmpl +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/extracted/api-contract.md.j2 +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/extracted/architecture-overview.md.j2 +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/extracted/module-detail.md.j2 +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/extracted/schema-design.md.j2 +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/extracted/system-context.md.j2 +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/gitignore.tmpl +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/lexicon_schema.yaml +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/overrides.yaml.tmpl +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/traceability.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/validator.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/wiring.py +0 -0
- {codd_dev-1.22.0 → codd_dev-1.23.0}/docs/requirements/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codd-dev
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.23.0
|
|
4
4
|
Summary: CoDD: Coherence-Driven Development — cross-artifact change impact analysis
|
|
5
5
|
Project-URL: Homepage, https://github.com/yohey-w/codd-dev
|
|
6
6
|
Project-URL: Repository, https://github.com/yohey-w/codd-dev
|
|
@@ -19,6 +19,7 @@ Requires-Dist: click>=8.0
|
|
|
19
19
|
Requires-Dist: jinja2>=3.1.0
|
|
20
20
|
Requires-Dist: pyyaml>=6.0
|
|
21
21
|
Requires-Dist: tomli>=2.0.1; python_version < '3.11'
|
|
22
|
+
Requires-Dist: watchdog>=4.0.0
|
|
22
23
|
Provides-Extra: ai
|
|
23
24
|
Provides-Extra: api-parsers
|
|
24
25
|
Requires-Dist: graphql-core>=3.2.0; extra == 'api-parsers'
|
|
@@ -950,23 +951,49 @@ You: yes
|
|
|
950
951
|
|
|
951
952
|
### Hook Integration — Set It Once, Never Think Again
|
|
952
953
|
|
|
953
|
-
|
|
954
|
+
CoDD ships copyable hook recipes in `codd/hooks/recipes/` so Claude, Codex, and Git can all trigger the same change-driven propagation path:
|
|
955
|
+
`codd propagate-from --files <changed-file>`.
|
|
956
|
+
|
|
957
|
+
#### Claude PostToolUse
|
|
958
|
+
|
|
959
|
+
Use `codd/hooks/recipes/claude_settings_example.json` as the starting point for `.claude/settings.json`. It listens for Edit / Write / MultiEdit and extracts changed files from `TOOL_INPUT` before calling `propagate-from`.
|
|
954
960
|
|
|
955
961
|
```json
|
|
956
962
|
{
|
|
957
963
|
"hooks": {
|
|
958
964
|
"PostToolUse": [{
|
|
959
|
-
"matcher": "Edit|Write",
|
|
965
|
+
"matcher": "Edit|Write|MultiEdit",
|
|
960
966
|
"hooks": [{
|
|
961
967
|
"type": "command",
|
|
962
|
-
"command": "codd
|
|
968
|
+
"command": "python -m codd propagate-from --files <changed-file> --source editor_hook --editor claude"
|
|
963
969
|
}]
|
|
964
970
|
}]
|
|
965
971
|
}
|
|
966
972
|
}
|
|
967
973
|
```
|
|
968
974
|
|
|
969
|
-
|
|
975
|
+
#### Codex Post-Edit
|
|
976
|
+
|
|
977
|
+
Use `codd/hooks/recipes/codex_hook.sh` when your Codex wrapper can pass edited files through `CODEX_EDITED_FILES`:
|
|
978
|
+
|
|
979
|
+
```bash
|
|
980
|
+
export CODEX_EDITED_FILES="src/app.ts,docs/design/api.md"
|
|
981
|
+
bash codd/hooks/recipes/codex_hook.sh
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
#### Git Hooks
|
|
985
|
+
|
|
986
|
+
Use the git recipes as a final catch for manual edits and non-editor workflows:
|
|
987
|
+
|
|
988
|
+
```bash
|
|
989
|
+
cp codd/hooks/recipes/git_pre_commit.sh .git/hooks/pre-commit
|
|
990
|
+
cp codd/hooks/recipes/git_post_commit.sh .git/hooks/post-commit
|
|
991
|
+
chmod +x .git/hooks/pre-commit .git/hooks/post-commit
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
The pre-commit hook runs `propagate-from` in `--dry-run` mode against staged files. The post-commit hook runs propagation against the committed file set.
|
|
995
|
+
|
|
996
|
+
With hooks active, your entire workflow becomes: **edit files normally, and CoDD propagates the change from the files that actually changed.** The graph maintenance is invisible.
|
|
970
997
|
|
|
971
998
|
### Available Skills
|
|
972
999
|
|
|
@@ -912,23 +912,49 @@ You: yes
|
|
|
912
912
|
|
|
913
913
|
### Hook Integration — Set It Once, Never Think Again
|
|
914
914
|
|
|
915
|
-
|
|
915
|
+
CoDD ships copyable hook recipes in `codd/hooks/recipes/` so Claude, Codex, and Git can all trigger the same change-driven propagation path:
|
|
916
|
+
`codd propagate-from --files <changed-file>`.
|
|
917
|
+
|
|
918
|
+
#### Claude PostToolUse
|
|
919
|
+
|
|
920
|
+
Use `codd/hooks/recipes/claude_settings_example.json` as the starting point for `.claude/settings.json`. It listens for Edit / Write / MultiEdit and extracts changed files from `TOOL_INPUT` before calling `propagate-from`.
|
|
916
921
|
|
|
917
922
|
```json
|
|
918
923
|
{
|
|
919
924
|
"hooks": {
|
|
920
925
|
"PostToolUse": [{
|
|
921
|
-
"matcher": "Edit|Write",
|
|
926
|
+
"matcher": "Edit|Write|MultiEdit",
|
|
922
927
|
"hooks": [{
|
|
923
928
|
"type": "command",
|
|
924
|
-
"command": "codd
|
|
929
|
+
"command": "python -m codd propagate-from --files <changed-file> --source editor_hook --editor claude"
|
|
925
930
|
}]
|
|
926
931
|
}]
|
|
927
932
|
}
|
|
928
933
|
}
|
|
929
934
|
```
|
|
930
935
|
|
|
931
|
-
|
|
936
|
+
#### Codex Post-Edit
|
|
937
|
+
|
|
938
|
+
Use `codd/hooks/recipes/codex_hook.sh` when your Codex wrapper can pass edited files through `CODEX_EDITED_FILES`:
|
|
939
|
+
|
|
940
|
+
```bash
|
|
941
|
+
export CODEX_EDITED_FILES="src/app.ts,docs/design/api.md"
|
|
942
|
+
bash codd/hooks/recipes/codex_hook.sh
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
#### Git Hooks
|
|
946
|
+
|
|
947
|
+
Use the git recipes as a final catch for manual edits and non-editor workflows:
|
|
948
|
+
|
|
949
|
+
```bash
|
|
950
|
+
cp codd/hooks/recipes/git_pre_commit.sh .git/hooks/pre-commit
|
|
951
|
+
cp codd/hooks/recipes/git_post_commit.sh .git/hooks/post-commit
|
|
952
|
+
chmod +x .git/hooks/pre-commit .git/hooks/post-commit
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
The pre-commit hook runs `propagate-from` in `--dry-run` mode against staged files. The post-commit hook runs propagation against the committed file set.
|
|
956
|
+
|
|
957
|
+
With hooks active, your entire workflow becomes: **edit files normally, and CoDD propagates the change from the files that actually changed.** The graph maintenance is invisible.
|
|
932
958
|
|
|
933
959
|
### Available Skills
|
|
934
960
|
|
|
@@ -344,23 +344,65 @@ def scan(path: str):
|
|
|
344
344
|
run_scan(project_root, codd_dir)
|
|
345
345
|
|
|
346
346
|
|
|
347
|
-
@main.command()
|
|
348
|
-
@click.option("--diff", default="HEAD", help="Git diff target (default: HEAD, shows uncommitted changes)")
|
|
349
|
-
@click.option("--path", default=".", help="Project root directory")
|
|
350
|
-
@click.option("--output", default=None, help="Output file (default: stdout)")
|
|
351
|
-
def impact(diff: str, path: str, output: str):
|
|
347
|
+
@main.command()
|
|
348
|
+
@click.option("--diff", default="HEAD", help="Git diff target (default: HEAD, shows uncommitted changes)")
|
|
349
|
+
@click.option("--path", default=".", help="Project root directory")
|
|
350
|
+
@click.option("--output", default=None, help="Output file (default: stdout)")
|
|
351
|
+
def impact(diff: str, path: str, output: str):
|
|
352
352
|
"""Analyze change impact from git diff."""
|
|
353
353
|
from codd.propagate import run_impact
|
|
354
354
|
project_root = Path(path).resolve()
|
|
355
355
|
codd_dir = _require_codd_dir(project_root)
|
|
356
|
-
|
|
357
|
-
run_impact(project_root, codd_dir, diff, output)
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
@main.command()
|
|
361
|
-
@click.option("--
|
|
362
|
-
@click.option("--
|
|
363
|
-
@click.option("--
|
|
356
|
+
|
|
357
|
+
run_impact(project_root, codd_dir, diff, output)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@main.command("watch")
|
|
361
|
+
@click.option("--project-path", "--path", default=".", show_default=True, help="Project root directory")
|
|
362
|
+
@click.option("--debounce", default=500, show_default=True, type=int, help="Debounce interval in milliseconds")
|
|
363
|
+
@click.option("--background", is_flag=True, default=False, help="Run watcher in background mode")
|
|
364
|
+
@click.option("--status", is_flag=True, default=False, help="Show watcher status")
|
|
365
|
+
def watch_cmd(project_path: str, debounce: int, background: bool, status: bool) -> None:
|
|
366
|
+
"""Watch for file changes and emit CDAP file-change events."""
|
|
367
|
+
project_root = Path(project_path).resolve()
|
|
368
|
+
pid_file = project_root / ".codd" / "watch.pid"
|
|
369
|
+
|
|
370
|
+
if status:
|
|
371
|
+
if pid_file.exists():
|
|
372
|
+
click.echo(f"Watcher running (PID: {pid_file.read_text(encoding='utf-8').strip()})")
|
|
373
|
+
else:
|
|
374
|
+
click.echo("Watcher not running")
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
from codd.watch.events import FileChangeEvent
|
|
378
|
+
from codd.watch.watcher import start_watch
|
|
379
|
+
|
|
380
|
+
if not project_root.exists():
|
|
381
|
+
click.echo(f"Error: Project path does not exist: {project_root}")
|
|
382
|
+
raise SystemExit(1)
|
|
383
|
+
if not project_root.is_dir():
|
|
384
|
+
click.echo(f"Error: Project path is not a directory: {project_root}")
|
|
385
|
+
raise SystemExit(1)
|
|
386
|
+
|
|
387
|
+
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
388
|
+
pid_file.write_text(f"{os.getpid()}\n", encoding="utf-8")
|
|
389
|
+
|
|
390
|
+
def on_change(event: FileChangeEvent) -> None:
|
|
391
|
+
preview = ", ".join(event.files[:3])
|
|
392
|
+
suffix = "" if len(event.files) <= 3 else f", ... {len(event.files) - 3} more"
|
|
393
|
+
click.echo(f"[watch] {len(event.files)} file(s) changed: {preview}{suffix}")
|
|
394
|
+
|
|
395
|
+
click.echo(f"Watching {project_root} (debounce={debounce}ms)")
|
|
396
|
+
observer = start_watch(project_root, on_change, debounce_ms=debounce, background=background)
|
|
397
|
+
if background:
|
|
398
|
+
click.echo(f"Watcher running in background mode (PID: {os.getpid()})")
|
|
399
|
+
observer.join(timeout=0)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@main.command()
|
|
403
|
+
@click.option("--wave", required=True, type=click.IntRange(min=1), help="Wave number to generate")
|
|
404
|
+
@click.option("--path", default=".", help="Project root directory")
|
|
405
|
+
@click.option("--force", is_flag=True, help="Overwrite existing files")
|
|
364
406
|
@click.option(
|
|
365
407
|
"--ai-cmd",
|
|
366
408
|
default=None,
|
|
@@ -852,11 +894,47 @@ def propagate(diff: str, path: str, update: bool, verify: bool, do_commit: bool,
|
|
|
852
894
|
click.echo(f" [{status}] {doc.path} ({doc.node_id})")
|
|
853
895
|
click.echo(f" modules: {', '.join(doc.matched_modules)}")
|
|
854
896
|
|
|
855
|
-
if not update and result.affected_docs:
|
|
856
|
-
click.echo(f"\nRun with --update to update these docs via AI.")
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
@main.command()
|
|
897
|
+
if not update and result.affected_docs:
|
|
898
|
+
click.echo(f"\nRun with --update to update these docs via AI.")
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
@main.command("propagate-from")
|
|
902
|
+
@click.option("--project-path", default=".", show_default=True, help="Project root directory")
|
|
903
|
+
@click.option("--files", multiple=True, required=True, help="Changed file path. Can be repeated.")
|
|
904
|
+
@click.option(
|
|
905
|
+
"--source",
|
|
906
|
+
default="manual",
|
|
907
|
+
show_default=True,
|
|
908
|
+
type=click.Choice(["watch", "git_hook", "editor_hook", "manual"]),
|
|
909
|
+
help="Change source that triggered propagation.",
|
|
910
|
+
)
|
|
911
|
+
@click.option(
|
|
912
|
+
"--editor",
|
|
913
|
+
default=None,
|
|
914
|
+
type=click.Choice(["claude", "codex", "manual"]),
|
|
915
|
+
help="Editor that produced the change, when known.",
|
|
916
|
+
)
|
|
917
|
+
@click.option("--dry-run", is_flag=True, default=False, help="Compute impact without propagate/fix/log writes.")
|
|
918
|
+
def propagate_from(project_path: str, files: tuple[str, ...], source: str, editor: str | None, dry_run: bool):
|
|
919
|
+
"""Run the CDAP propagation pipeline from changed files."""
|
|
920
|
+
from codd.watch.events import FileChangeEvent
|
|
921
|
+
from codd.watch.propagation_pipeline import run_propagation_pipeline
|
|
922
|
+
|
|
923
|
+
project_root = Path(project_path).resolve()
|
|
924
|
+
event = FileChangeEvent(files=list(files), source=source, editor=editor)
|
|
925
|
+
result = run_propagation_pipeline(project_root, list(files), dry_run=dry_run, event=event)
|
|
926
|
+
|
|
927
|
+
click.echo(f"Impacted nodes: {len(result.impacted_nodes)}")
|
|
928
|
+
click.echo(f"Propagated: {result.propagated_count}")
|
|
929
|
+
click.echo(f"Fixed: {result.fixed_count}")
|
|
930
|
+
if result.errors:
|
|
931
|
+
click.echo(f"Errors: {result.errors}", err=True)
|
|
932
|
+
|
|
933
|
+
if not result.success:
|
|
934
|
+
raise SystemExit(1)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
@main.command()
|
|
860
938
|
@click.option("--path", default=".", help="Project root directory")
|
|
861
939
|
@click.option("--task", default=None, help="Generate only one task by task ID or title match")
|
|
862
940
|
@click.option("--clean", is_flag=True, default=False, help="Remove existing generated output before re-generating")
|
|
@@ -2183,6 +2261,28 @@ def mcp_server(project: str):
|
|
|
2183
2261
|
run_stdio(project_root)
|
|
2184
2262
|
|
|
2185
2263
|
|
|
2264
|
+
@main.command("test")
|
|
2265
|
+
@click.option("--project-path", "--path", default=".", show_default=True, help="Project root directory")
|
|
2266
|
+
@click.option("--related", multiple=True, help="Run only tests related to these files")
|
|
2267
|
+
@click.option("--dry-run", is_flag=True, default=False, help="Print the related test command without running it")
|
|
2268
|
+
def test_cmd(project_path: str, related: tuple[str, ...], dry_run: bool):
|
|
2269
|
+
"""Run tests. Use --related <file> to run only related tests."""
|
|
2270
|
+
from codd.watch.test_runner import run_related_tests
|
|
2271
|
+
|
|
2272
|
+
project_root = Path(project_path).resolve()
|
|
2273
|
+
if not related:
|
|
2274
|
+
click.echo("Use --related <file> to specify files. Full test run not supported via this command.")
|
|
2275
|
+
return
|
|
2276
|
+
|
|
2277
|
+
result = run_related_tests(project_root, list(related), dry_run=dry_run)
|
|
2278
|
+
click.echo(f"Related tests: {result['related']}")
|
|
2279
|
+
if result.get("cmd"):
|
|
2280
|
+
click.echo(f"Command: {result['cmd']}")
|
|
2281
|
+
click.echo(f"Status: {result['status']}")
|
|
2282
|
+
if result.get("exit_code") not in (None, 0):
|
|
2283
|
+
raise SystemExit(1)
|
|
2284
|
+
|
|
2285
|
+
|
|
2186
2286
|
@main.group()
|
|
2187
2287
|
def dag():
|
|
2188
2288
|
"""DAG Completeness Gate commands."""
|
|
@@ -80,7 +80,6 @@ def set_coherence_bus(bus: EventBus | None) -> None:
|
|
|
80
80
|
"""Set the opt-in coherence bus on detectors that publish DriftEvents."""
|
|
81
81
|
for module_name in (
|
|
82
82
|
"codd.drift",
|
|
83
|
-
"codd.drift_linkers.api",
|
|
84
83
|
"codd.deployer",
|
|
85
84
|
"codd.hitl_session",
|
|
86
85
|
"codd.validator",
|
|
@@ -134,7 +134,7 @@ def compute_screen_flow_coverage(
|
|
|
134
134
|
config: dict[str, Any],
|
|
135
135
|
threshold: float = 100.0,
|
|
136
136
|
) -> CoverageResult:
|
|
137
|
-
"""Measure screen-flow drift as a coverage gate metric."""
|
|
137
|
+
"""Measure screen-flow route drift as a coverage gate metric."""
|
|
138
138
|
|
|
139
139
|
try:
|
|
140
140
|
from codd.cli import CoddCLIError
|
|
@@ -153,25 +153,9 @@ def compute_screen_flow_coverage(
|
|
|
153
153
|
details=[f"error: {exc}"],
|
|
154
154
|
)
|
|
155
155
|
|
|
156
|
-
|
|
157
|
-
design_drift_count = 0
|
|
158
|
-
try:
|
|
159
|
-
from codd.drift_linkers.screen_flow import ScreenFlowGate
|
|
160
|
-
|
|
161
|
-
gate_result = ScreenFlowGate(
|
|
162
|
-
project_root=project_root,
|
|
163
|
-
settings={**config, "apply": True},
|
|
164
|
-
).run()
|
|
165
|
-
if not gate_result.skipped:
|
|
166
|
-
design_drift_count = gate_result.drift_count
|
|
167
|
-
design_drift_details = gate_result.details
|
|
168
|
-
except Exception as exc: # pragma: no cover - defensive gate behavior
|
|
169
|
-
return _exception_result("screen_flow_coverage", threshold, exc)
|
|
170
|
-
|
|
171
|
-
drift_count = len(drifts) + design_drift_count
|
|
156
|
+
drift_count = len(drifts)
|
|
172
157
|
pct = 100.0 if drift_count == 0 else max(0.0, 100.0 - drift_count * 10.0)
|
|
173
158
|
details = [f"drift_count: {drift_count}"]
|
|
174
|
-
details.extend(design_drift_details)
|
|
175
159
|
return CoverageResult(
|
|
176
160
|
metric="screen_flow_coverage",
|
|
177
161
|
total=1,
|
|
@@ -19,8 +19,10 @@ from codd.dag.extractor import extract_design_doc_metadata, extract_imports
|
|
|
19
19
|
DEFAULTS_DIR = Path(__file__).parent / "defaults"
|
|
20
20
|
DEFAULT_PROJECT_TYPE = "web"
|
|
21
21
|
IMPLEMENTATION_SUFFIXES = (".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".java")
|
|
22
|
+
TEST_SUFFIXES = (".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".bats")
|
|
22
23
|
PLAN_HEADER_RE = re.compile(r"^#{2,6}\s+([A-Za-z0-9]+(?:[-_.][A-Za-z0-9]+)*)(?:\s+(.+))?$", re.MULTILINE)
|
|
23
24
|
OUTPUTS_RE = re.compile(r"(?im)^outputs?[ \t]*:[ \t]*(.*)$")
|
|
25
|
+
PY_IMPORT_RE = re.compile(r"(?m)^\s*(?:from\s+([A-Za-z_][\w.]*)(?:\s+import\s+)|import\s+([A-Za-z_][\w.]*))")
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
def build_dag(project_root: Path, settings: dict[str, Any] | None = None) -> DAG:
|
|
@@ -35,9 +37,11 @@ def build_dag(project_root: Path, settings: dict[str, Any] | None = None) -> DAG
|
|
|
35
37
|
dag = DAG()
|
|
36
38
|
design_docs = _add_design_docs(dag, root, dag_settings)
|
|
37
39
|
impl_nodes = _add_impl_files(dag, root, dag_settings)
|
|
40
|
+
test_nodes = _add_test_files(dag, root, dag_settings)
|
|
38
41
|
|
|
39
42
|
_add_design_edges(dag, root, design_docs, impl_nodes)
|
|
40
43
|
_add_import_edges(dag, root, impl_nodes, dag_settings)
|
|
44
|
+
_add_tested_by_edges(dag, root, impl_nodes, test_nodes, dag_settings)
|
|
41
45
|
_add_plan_tasks(dag, root, dag_settings)
|
|
42
46
|
_add_expected_nodes(dag, root, dag_settings, impl_nodes)
|
|
43
47
|
|
|
@@ -59,9 +63,12 @@ def load_dag_settings(project_root: Path, settings: dict[str, Any] | None = None
|
|
|
59
63
|
merged = _read_default_settings(project_type)
|
|
60
64
|
merged = _deep_merge(merged, _dag_overrides(project_config))
|
|
61
65
|
merged = _deep_merge(merged, _dag_overrides(requested_settings))
|
|
66
|
+
_apply_scan_patterns(merged, project_config)
|
|
67
|
+
_apply_scan_patterns(merged, requested_settings)
|
|
62
68
|
merged["project_type"] = project_type
|
|
63
69
|
merged.setdefault("design_doc_patterns", [])
|
|
64
70
|
merged.setdefault("impl_file_patterns", [])
|
|
71
|
+
merged.setdefault("test_file_patterns", [])
|
|
65
72
|
merged.setdefault("plan_task_file", "docs/design/implementation_plan.md")
|
|
66
73
|
merged.setdefault("lexicon_file", "project_lexicon.yaml")
|
|
67
74
|
return merged
|
|
@@ -160,7 +167,11 @@ def _add_design_docs(dag: DAG, project_root: Path, settings: dict[str, Any]) ->
|
|
|
160
167
|
def _add_impl_files(dag: DAG, project_root: Path, settings: dict[str, Any]) -> dict[str, Path]:
|
|
161
168
|
impl_nodes: dict[str, Path] = {}
|
|
162
169
|
for file_path in _glob_project_paths(project_root, settings.get("impl_file_patterns", [])):
|
|
163
|
-
if
|
|
170
|
+
if (
|
|
171
|
+
not file_path.is_file()
|
|
172
|
+
or file_path.suffix not in IMPLEMENTATION_SUFFIXES
|
|
173
|
+
or _is_test_file(file_path, project_root)
|
|
174
|
+
):
|
|
164
175
|
continue
|
|
165
176
|
node_id = _relative_id(file_path, project_root)
|
|
166
177
|
impl_nodes[node_id] = file_path.resolve()
|
|
@@ -179,6 +190,28 @@ def _add_impl_files(dag: DAG, project_root: Path, settings: dict[str, Any]) -> d
|
|
|
179
190
|
return impl_nodes
|
|
180
191
|
|
|
181
192
|
|
|
193
|
+
def _add_test_files(dag: DAG, project_root: Path, settings: dict[str, Any]) -> dict[str, Path]:
|
|
194
|
+
test_nodes: dict[str, Path] = {}
|
|
195
|
+
for file_path in _glob_project_paths(project_root, settings.get("test_file_patterns", [])):
|
|
196
|
+
if not file_path.is_file() or file_path.suffix not in TEST_SUFFIXES or not _is_test_file(file_path, project_root):
|
|
197
|
+
continue
|
|
198
|
+
node_id = _relative_id(file_path, project_root)
|
|
199
|
+
test_nodes[node_id] = file_path.resolve()
|
|
200
|
+
_add_node_once(
|
|
201
|
+
dag,
|
|
202
|
+
Node(
|
|
203
|
+
id=node_id,
|
|
204
|
+
kind="test_file",
|
|
205
|
+
path=node_id,
|
|
206
|
+
attributes={
|
|
207
|
+
"language": _language_for_path(file_path),
|
|
208
|
+
"imports": _extract_test_imports(file_path),
|
|
209
|
+
},
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
return test_nodes
|
|
213
|
+
|
|
214
|
+
|
|
182
215
|
def _add_design_edges(
|
|
183
216
|
dag: DAG,
|
|
184
217
|
project_root: Path,
|
|
@@ -217,6 +250,25 @@ def _add_import_edges(
|
|
|
217
250
|
dag.add_edge(Edge(from_id=node_id, to_id=target_id, kind="imports"))
|
|
218
251
|
|
|
219
252
|
|
|
253
|
+
def _add_tested_by_edges(
|
|
254
|
+
dag: DAG,
|
|
255
|
+
project_root: Path,
|
|
256
|
+
impl_nodes: dict[str, Path],
|
|
257
|
+
test_nodes: dict[str, Path],
|
|
258
|
+
settings: dict[str, Any],
|
|
259
|
+
) -> None:
|
|
260
|
+
path_to_node = {path: node_id for node_id, path in impl_nodes.items()}
|
|
261
|
+
aliases = _load_import_aliases(project_root, settings)
|
|
262
|
+
existing_edges = {(edge.from_id, edge.to_id, edge.kind) for edge in dag.edges}
|
|
263
|
+
|
|
264
|
+
for test_id, test_path in test_nodes.items():
|
|
265
|
+
for target_id in _infer_test_targets(test_path, project_root, path_to_node, aliases):
|
|
266
|
+
edge_key = (target_id, test_id, "tested_by")
|
|
267
|
+
if edge_key not in existing_edges:
|
|
268
|
+
dag.add_edge(Edge(from_id=target_id, to_id=test_id, kind="tested_by"))
|
|
269
|
+
existing_edges.add(edge_key)
|
|
270
|
+
|
|
271
|
+
|
|
220
272
|
def _add_plan_tasks(dag: DAG, project_root: Path, settings: dict[str, Any]) -> None:
|
|
221
273
|
plan_path = _project_path(project_root, str(settings.get("plan_task_file", "")))
|
|
222
274
|
if not plan_path.is_file():
|
|
@@ -374,6 +426,52 @@ def _resolve_import_target(
|
|
|
374
426
|
return None
|
|
375
427
|
|
|
376
428
|
|
|
429
|
+
def _infer_test_targets(
|
|
430
|
+
test_path: Path,
|
|
431
|
+
project_root: Path,
|
|
432
|
+
path_to_node: dict[Path, str],
|
|
433
|
+
aliases: dict[str, list[str]],
|
|
434
|
+
) -> set[str]:
|
|
435
|
+
targets: set[str] = set()
|
|
436
|
+
|
|
437
|
+
for import_ref in _extract_test_imports(test_path):
|
|
438
|
+
target_id = _resolve_import_target(import_ref, test_path, project_root, path_to_node, aliases)
|
|
439
|
+
if not target_id and "." in import_ref and not import_ref.startswith("."):
|
|
440
|
+
target_id = _resolve_python_import_target(import_ref, project_root, path_to_node)
|
|
441
|
+
if target_id:
|
|
442
|
+
targets.add(target_id)
|
|
443
|
+
|
|
444
|
+
convention_key = _test_convention_key(test_path, project_root)
|
|
445
|
+
if convention_key:
|
|
446
|
+
targets.update(_match_impl_by_convention(convention_key, path_to_node))
|
|
447
|
+
for candidate in _convention_path_candidates(test_path, project_root, convention_key):
|
|
448
|
+
target_id = _resolve_file_candidate(candidate, path_to_node)
|
|
449
|
+
if target_id:
|
|
450
|
+
targets.add(target_id)
|
|
451
|
+
|
|
452
|
+
return targets
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _extract_test_imports(file_path: Path) -> list[str]:
|
|
456
|
+
imports = extract_imports(file_path)
|
|
457
|
+
if file_path.suffix != ".py":
|
|
458
|
+
return imports
|
|
459
|
+
|
|
460
|
+
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
461
|
+
python_imports = [match.group(1) or match.group(2) for match in PY_IMPORT_RE.finditer(content)]
|
|
462
|
+
return [*imports, *python_imports]
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _resolve_python_import_target(import_ref: str, project_root: Path, path_to_node: dict[Path, str]) -> str | None:
|
|
466
|
+
module_path = (project_root / import_ref.replace(".", "/")).resolve()
|
|
467
|
+
resolved = _resolve_file_candidate(module_path, path_to_node)
|
|
468
|
+
if resolved:
|
|
469
|
+
return resolved
|
|
470
|
+
|
|
471
|
+
init_path = module_path / "__init__.py"
|
|
472
|
+
return path_to_node.get(init_path)
|
|
473
|
+
|
|
474
|
+
|
|
377
475
|
def _resolve_file_candidate(candidate: Path, path_to_node: dict[Path, str]) -> str | None:
|
|
378
476
|
if candidate in path_to_node:
|
|
379
477
|
return path_to_node[candidate]
|
|
@@ -388,9 +486,75 @@ def _resolve_file_candidate(candidate: Path, path_to_node: dict[Path, str]) -> s
|
|
|
388
486
|
indexed = candidate / f"index{suffix}"
|
|
389
487
|
if indexed in path_to_node:
|
|
390
488
|
return path_to_node[indexed]
|
|
489
|
+
init_file = candidate / "__init__.py"
|
|
490
|
+
if init_file in path_to_node:
|
|
491
|
+
return path_to_node[init_file]
|
|
391
492
|
return None
|
|
392
493
|
|
|
393
494
|
|
|
495
|
+
def _test_convention_key(test_path: Path, project_root: Path) -> str | None:
|
|
496
|
+
relative_parts = _relative_id(test_path, project_root).split("/")
|
|
497
|
+
name = test_path.name
|
|
498
|
+
|
|
499
|
+
for marker in (".test.", ".spec."):
|
|
500
|
+
if marker in name:
|
|
501
|
+
return name.split(marker, 1)[0]
|
|
502
|
+
|
|
503
|
+
if test_path.suffix == ".py" and any(part in {"tests", "test"} for part in relative_parts[:-1]):
|
|
504
|
+
stem = test_path.stem
|
|
505
|
+
if stem.startswith("test_"):
|
|
506
|
+
return stem.removeprefix("test_")
|
|
507
|
+
if stem.endswith("_test"):
|
|
508
|
+
return stem.removesuffix("_test")
|
|
509
|
+
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _match_impl_by_convention(convention_key: str, path_to_node: dict[Path, str]) -> set[str]:
|
|
514
|
+
matches: set[str] = set()
|
|
515
|
+
for _impl_path, node_id in path_to_node.items():
|
|
516
|
+
if convention_key in _impl_convention_tokens(node_id):
|
|
517
|
+
matches.add(node_id)
|
|
518
|
+
return matches
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _impl_convention_tokens(node_id: str) -> set[str]:
|
|
522
|
+
path = Path(node_id)
|
|
523
|
+
parts = [*path.with_suffix("").parts]
|
|
524
|
+
tokens = {path.stem}
|
|
525
|
+
if len(parts) >= 2:
|
|
526
|
+
tokens.add("_".join(parts[-2:]))
|
|
527
|
+
if len(parts) >= 3:
|
|
528
|
+
tokens.add("_".join(parts[-3:]))
|
|
529
|
+
return tokens
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _convention_path_candidates(test_path: Path, project_root: Path, convention_key: str) -> list[Path]:
|
|
533
|
+
candidates: list[Path] = []
|
|
534
|
+
suffixes = [".py"] if test_path.suffix == ".py" else [".ts", ".tsx", ".js", ".jsx"]
|
|
535
|
+
|
|
536
|
+
if any(marker in test_path.name for marker in (".test.", ".spec.")):
|
|
537
|
+
candidates.append((test_path.parent / convention_key).resolve())
|
|
538
|
+
|
|
539
|
+
for root_name in ("codd", "src"):
|
|
540
|
+
for suffix in suffixes:
|
|
541
|
+
candidates.append((project_root / root_name / f"{convention_key}{suffix}").resolve())
|
|
542
|
+
|
|
543
|
+
return candidates
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _is_test_file(file_path: Path, project_root: Path) -> bool:
|
|
547
|
+
relative_parts = _relative_id(file_path, project_root).split("/")
|
|
548
|
+
name = file_path.name
|
|
549
|
+
if any(marker in name for marker in (".test.", ".spec.")):
|
|
550
|
+
return True
|
|
551
|
+
if file_path.suffix == ".bats":
|
|
552
|
+
return True
|
|
553
|
+
if file_path.suffix == ".py" and any(part in {"tests", "test"} for part in relative_parts[:-1]):
|
|
554
|
+
return name.startswith("test_") or name.endswith("_test.py")
|
|
555
|
+
return False
|
|
556
|
+
|
|
557
|
+
|
|
394
558
|
def _load_import_aliases(project_root: Path, settings: dict[str, Any]) -> dict[str, list[str]]:
|
|
395
559
|
aliases: dict[str, list[str]] = {}
|
|
396
560
|
configured = settings.get("import_aliases", {})
|
|
@@ -467,6 +631,7 @@ def _dag_overrides(config: dict[str, Any]) -> dict[str, Any]:
|
|
|
467
631
|
direct_keys = {
|
|
468
632
|
"design_doc_patterns",
|
|
469
633
|
"impl_file_patterns",
|
|
634
|
+
"test_file_patterns",
|
|
470
635
|
"plan_task_file",
|
|
471
636
|
"lexicon_file",
|
|
472
637
|
"import_aliases",
|
|
@@ -477,6 +642,31 @@ def _dag_overrides(config: dict[str, Any]) -> dict[str, Any]:
|
|
|
477
642
|
return overrides
|
|
478
643
|
|
|
479
644
|
|
|
645
|
+
def _apply_scan_patterns(settings: dict[str, Any], config: dict[str, Any]) -> None:
|
|
646
|
+
scan = config.get("scan", {})
|
|
647
|
+
if not isinstance(scan, dict):
|
|
648
|
+
return
|
|
649
|
+
|
|
650
|
+
source_dirs = _as_list(scan.get("source_dirs"))
|
|
651
|
+
test_dirs = _as_list(scan.get("test_dirs"))
|
|
652
|
+
doc_dirs = _as_list(scan.get("doc_dirs"))
|
|
653
|
+
|
|
654
|
+
if source_dirs:
|
|
655
|
+
_extend_unique(
|
|
656
|
+
settings,
|
|
657
|
+
"impl_file_patterns",
|
|
658
|
+
_file_patterns_for_dirs(source_dirs, IMPLEMENTATION_SUFFIXES),
|
|
659
|
+
)
|
|
660
|
+
if test_dirs:
|
|
661
|
+
_extend_unique(
|
|
662
|
+
settings,
|
|
663
|
+
"test_file_patterns",
|
|
664
|
+
_file_patterns_for_dirs(test_dirs, TEST_SUFFIXES),
|
|
665
|
+
)
|
|
666
|
+
if doc_dirs:
|
|
667
|
+
_extend_unique(settings, "design_doc_patterns", _file_patterns_for_dirs(doc_dirs, (".md",)))
|
|
668
|
+
|
|
669
|
+
|
|
480
670
|
def _normalize_dag_section(section: dict[str, Any]) -> dict[str, Any]:
|
|
481
671
|
normalized = deepcopy(section)
|
|
482
672
|
node_extraction = normalized.pop("node_extraction", None)
|
|
@@ -485,6 +675,8 @@ def _normalize_dag_section(section: dict[str, Any]) -> dict[str, Any]:
|
|
|
485
675
|
normalized["design_doc_patterns"] = node_extraction["design_glob"]
|
|
486
676
|
if "impl_glob" in node_extraction:
|
|
487
677
|
normalized["impl_file_patterns"] = node_extraction["impl_glob"]
|
|
678
|
+
if "test_glob" in node_extraction:
|
|
679
|
+
normalized["test_file_patterns"] = node_extraction["test_glob"]
|
|
488
680
|
if "plan_path" in node_extraction:
|
|
489
681
|
normalized["plan_task_file"] = node_extraction["plan_path"]
|
|
490
682
|
return normalized
|
|
@@ -522,6 +714,27 @@ def _as_list(value: Any) -> list[Any]:
|
|
|
522
714
|
return [value]
|
|
523
715
|
|
|
524
716
|
|
|
717
|
+
def _file_patterns_for_dirs(dirs: list[Any], suffixes: tuple[str, ...]) -> list[str]:
|
|
718
|
+
patterns: list[str] = []
|
|
719
|
+
for directory in dirs:
|
|
720
|
+
if not isinstance(directory, str) or not directory.strip():
|
|
721
|
+
continue
|
|
722
|
+
base = directory.strip().strip("/")
|
|
723
|
+
if not base or base == ".":
|
|
724
|
+
base = "**"
|
|
725
|
+
for suffix in suffixes:
|
|
726
|
+
patterns.append(f"{base}/**/*{suffix}")
|
|
727
|
+
return patterns
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _extend_unique(settings: dict[str, Any], key: str, values: list[str]) -> None:
|
|
731
|
+
current = [str(item) for item in _as_list(settings.get(key)) if item]
|
|
732
|
+
for value in values:
|
|
733
|
+
if value not in current:
|
|
734
|
+
current.append(value)
|
|
735
|
+
settings[key] = current
|
|
736
|
+
|
|
737
|
+
|
|
525
738
|
def _add_node_once(dag: DAG, node: Node) -> None:
|
|
526
739
|
if node.id not in dag.nodes:
|
|
527
740
|
dag.add_node(node)
|
|
@@ -11,4 +11,14 @@ impl_file_patterns:
|
|
|
11
11
|
- "src/**/*.tsx"
|
|
12
12
|
- "app/**/*.ts"
|
|
13
13
|
- "app/**/*.tsx"
|
|
14
|
+
test_file_patterns:
|
|
15
|
+
- "tests/**/*.py"
|
|
16
|
+
- "tests/**/*.ts"
|
|
17
|
+
- "tests/**/*.tsx"
|
|
18
|
+
- "tests/**/*.js"
|
|
19
|
+
- "tests/**/*.jsx"
|
|
20
|
+
- "**/*.test.ts"
|
|
21
|
+
- "**/*.test.tsx"
|
|
22
|
+
- "**/*.spec.ts"
|
|
23
|
+
- "**/*.spec.tsx"
|
|
14
24
|
plan_task_file: "docs/design/implementation_plan.md"
|