hexdag-forge 0.2.0.dev2__tar.gz → 0.2.0.dev3__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.
- hexdag_forge-0.2.0.dev3/PKG-INFO +137 -0
- hexdag_forge-0.2.0.dev3/README.md +103 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/cli.py +5 -5
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/domain/__init__.py +12 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/domain/business_profile.py +26 -1
- hexdag_forge-0.2.0.dev3/hexdag_forge/domain/profile_diff.py +67 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/generator/assembler.py +27 -4
- hexdag_forge-0.2.0.dev3/hexdag_forge/generator/diff.py +157 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/generator/django_generator.py +73 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/generator/field_mapping.py +93 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/generator/migration_generator.py +249 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/generator/template_engine.py +64 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/mcp/server.py +89 -34
- hexdag_forge-0.2.0.dev3/hexdag_forge/skills/forge.md +57 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/skills/forge_entity.md +37 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/skills/forge_implement.md +2 -2
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/skills/forge_test.md +1 -1
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/templates/django_admin.py.j2 +15 -1
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_apps.py.j2 +12 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_base.html.j2 +50 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_chat.html.j2 +43 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_chat_conversations.html.j2 +29 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_chat_messages_partial.html.j2 +8 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_entity_confirm_delete.html.j2 +15 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_entity_detail.html.j2 +14 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_entity_detail_partial.html.j2 +29 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_entity_form.html.j2 +15 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_entity_list.html.j2 +45 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_entity_list_partial.html.j2 +11 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_forms.py.j2 +32 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_logged_out.html.j2 +9 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_login.html.j2 +13 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_migration.py.j2 +24 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/templates/django_model.py.j2 +35 -1
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_register.html.j2 +13 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/templates/django_settings.py.j2 +6 -1
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_urls.py.j2 +53 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_views.py.j2 +214 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/templates/service.py.j2 +1 -1
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/pyproject.toml +1 -1
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/tests/test_domain.py +48 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/tests/test_generator/test_assembler.py +68 -4
- hexdag_forge-0.2.0.dev3/tests/test_generator/test_diff.py +200 -0
- hexdag_forge-0.2.0.dev3/tests/test_generator/test_migration_generator.py +187 -0
- hexdag_forge-0.2.0.dev2/PKG-INFO +0 -101
- hexdag_forge-0.2.0.dev2/README.md +0 -67
- hexdag_forge-0.2.0.dev2/hexdag_forge/generator/business_analyzer.py +0 -161
- hexdag_forge-0.2.0.dev2/hexdag_forge/generator/django_generator.py +0 -27
- hexdag_forge-0.2.0.dev2/hexdag_forge/generator/template_engine.py +0 -27
- hexdag_forge-0.2.0.dev2/hexdag_forge/skills/forge.md +0 -34
- hexdag_forge-0.2.0.dev2/hexdag_forge/skills/forge_entity.md +0 -25
- hexdag_forge-0.2.0.dev2/hexdag_forge/templates/django_urls.py.j2 +0 -11
- hexdag_forge-0.2.0.dev2/tests/test_generator/test_business_analyzer.py +0 -35
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/.gitignore +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/LICENSE +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/domain/enums.py +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/domain/generated_system.py +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/generator/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/generator/entity_generator.py +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/generator/pipeline_generator.py +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/generator/service_generator.py +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/generator/system_generator.py +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/mcp/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/mcp/__main__.py +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/templates/django_manage.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/templates/pipeline.yaml.j2 +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/templates/state_machines.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/hexdag_forge/templates/system.yaml.j2 +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/tests/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev3}/tests/test_generator/__init__.py +0 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hexdag-forge
|
|
3
|
+
Version: 0.2.0.dev3
|
|
4
|
+
Summary: Generate operational systems from prompts — code generator powered by hexDAG
|
|
5
|
+
Project-URL: Homepage, https://hexdag.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/omniviser/hexdag
|
|
7
|
+
Project-URL: Documentation, https://hexdag.ai/docs/forge
|
|
8
|
+
Project-URL: Bug Reports, https://github.com/omniviser/hexdag/issues
|
|
9
|
+
Author-email: hexDAG Team <developers@omniviser.ai>
|
|
10
|
+
License: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai,code-generation,crm,erp,forge,hexdag,operational-systems,tms
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Office/Business
|
|
19
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: hexdag>=0.7.0
|
|
22
|
+
Requires-Dist: jinja2>=3.1.0
|
|
23
|
+
Requires-Dist: pydantic>=2.0.0
|
|
24
|
+
Requires-Dist: typer>=0.9.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
31
|
+
Provides-Extra: mcp
|
|
32
|
+
Requires-Dist: mcp>=1.0.0; extra == 'mcp'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# hexDAG Forge
|
|
36
|
+
|
|
37
|
+
**Generate operational systems from prompts.** Describe your business, get a complete system with YAML pipelines, Python services, Django models, state machines, and auto-migrations.
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
|
|
41
|
+
1. You describe your business to Claude Code (via `/forge` or the MCP server)
|
|
42
|
+
2. Claude analyzes the description and builds a `BusinessProfile` JSON (entities, fields, states, transitions, relationships)
|
|
43
|
+
3. Forge generates the complete system from that profile
|
|
44
|
+
4. When you change the ontology, `forge_migrate` diffs the profiles and generates Django migrations automatically
|
|
45
|
+
|
|
46
|
+
## Output
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
./generated/
|
|
50
|
+
├── system.yaml # kind: System manifest (LifecycleRunner)
|
|
51
|
+
├── profile.json # BusinessProfile (source of truth)
|
|
52
|
+
├── settings.py / manage.py # Django project scaffold
|
|
53
|
+
├── admin.py / urls.py # Django admin auto-registration
|
|
54
|
+
├── models/
|
|
55
|
+
│ └── models.py # Django ORM models with fields + state choices
|
|
56
|
+
├── migrations/
|
|
57
|
+
│ └── 0001_auto.py # Initial Django migration (auto-generated)
|
|
58
|
+
├── state_machines.py # StateMachineConfig definitions
|
|
59
|
+
├── pipelines/
|
|
60
|
+
│ ├── load_lifecycle.yaml # hexDAG pipeline per entity
|
|
61
|
+
│ └── carrier_lifecycle.yaml
|
|
62
|
+
└── services/
|
|
63
|
+
├── load_service.py # @tool/@step CRUD (implemented) + domain stubs
|
|
64
|
+
└── carrier_service.py
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## What it generates
|
|
68
|
+
|
|
69
|
+
- **YAML Pipelines** -- entity lifecycle workflows using hexDAG nodes
|
|
70
|
+
- **Python Services** -- `@tool`/`@step` CRUD methods (implemented) + domain-specific stubs (`NotImplementedError`) for the builder agent to fill
|
|
71
|
+
- **Django Models** -- ORM models with fields, relationships, state choices
|
|
72
|
+
- **System Manifest** -- `kind: System` with state machines, `on_enter` process mappings, terminal states
|
|
73
|
+
- **State Machines** -- `StateMachineConfig` definitions with validated transitions
|
|
74
|
+
- **Django Migrations** -- auto-generated from profile diffs (CreateModel, AddField, AlterField, etc.)
|
|
75
|
+
|
|
76
|
+
## MCP + Claude Code
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install hexdag-forge
|
|
80
|
+
hexdag-forge mcp serve
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Skills
|
|
84
|
+
|
|
85
|
+
- `/forge "I run a freight brokerage"` -- generate a new system
|
|
86
|
+
- `/forge:entity "add Carrier with mc_number, score"` -- add/modify entity (with automigration)
|
|
87
|
+
- `/forge:implement "fill in negotiate_rate"` -- implement a service stub
|
|
88
|
+
- `/forge:test` -- validate generated files
|
|
89
|
+
|
|
90
|
+
### MCP Tools
|
|
91
|
+
|
|
92
|
+
| Tool | Description |
|
|
93
|
+
|------|-------------|
|
|
94
|
+
| `forge_profile_schema` | Get the BusinessProfile JSON schema |
|
|
95
|
+
| `forge_generate` | Generate a new system from a BusinessProfile JSON |
|
|
96
|
+
| `forge_migrate` | Update an existing system -- diffs profiles, regenerates, writes migration |
|
|
97
|
+
| `forge_get_profile` | Read the profile from a generated system |
|
|
98
|
+
| `forge_validate` | Validate generated files (syntax, structure) |
|
|
99
|
+
| `forge_list_stubs` | List NotImplementedError methods in services |
|
|
100
|
+
| `forge_implement_stub` | Replace a stub with implementation code |
|
|
101
|
+
|
|
102
|
+
## CLI
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Generate from a profile JSON file
|
|
106
|
+
hexdag-forge generate profile.json --output ./my-erp/
|
|
107
|
+
|
|
108
|
+
# Validate generated files
|
|
109
|
+
hexdag-forge validate ./my-erp/
|
|
110
|
+
|
|
111
|
+
# Start MCP server for Claude Code
|
|
112
|
+
hexdag-forge mcp serve
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Automigration
|
|
116
|
+
|
|
117
|
+
When you modify the ontology (add entities, change fields, rename states), forge automatically generates Django migration files:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
# Initial generation creates 0001_auto.py with CreateModel for each entity
|
|
121
|
+
/forge "freight brokerage with loads and carriers"
|
|
122
|
+
→ migrations/0001_auto.py (CreateModel: Load, Carrier)
|
|
123
|
+
|
|
124
|
+
# Adding a field via /forge:entity creates 0002_auto.py
|
|
125
|
+
/forge:entity "add weight_limit field to carrier"
|
|
126
|
+
→ migrations/0002_auto.py (AddField: carrier.weight_limit)
|
|
127
|
+
|
|
128
|
+
# Changing states creates another migration
|
|
129
|
+
/forge:entity "add SUSPENDED state to carrier"
|
|
130
|
+
→ migrations/0003_auto.py (AlterField: carrier.status choices)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Supported operations: `CreateModel`, `DeleteModel`, `AddField`, `RemoveField`, `AlterField` (type changes, option changes, state changes), `ForeignKey` additions/removals.
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
Apache 2.0
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# hexDAG Forge
|
|
2
|
+
|
|
3
|
+
**Generate operational systems from prompts.** Describe your business, get a complete system with YAML pipelines, Python services, Django models, state machines, and auto-migrations.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
1. You describe your business to Claude Code (via `/forge` or the MCP server)
|
|
8
|
+
2. Claude analyzes the description and builds a `BusinessProfile` JSON (entities, fields, states, transitions, relationships)
|
|
9
|
+
3. Forge generates the complete system from that profile
|
|
10
|
+
4. When you change the ontology, `forge_migrate` diffs the profiles and generates Django migrations automatically
|
|
11
|
+
|
|
12
|
+
## Output
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
./generated/
|
|
16
|
+
├── system.yaml # kind: System manifest (LifecycleRunner)
|
|
17
|
+
├── profile.json # BusinessProfile (source of truth)
|
|
18
|
+
├── settings.py / manage.py # Django project scaffold
|
|
19
|
+
├── admin.py / urls.py # Django admin auto-registration
|
|
20
|
+
├── models/
|
|
21
|
+
│ └── models.py # Django ORM models with fields + state choices
|
|
22
|
+
├── migrations/
|
|
23
|
+
│ └── 0001_auto.py # Initial Django migration (auto-generated)
|
|
24
|
+
├── state_machines.py # StateMachineConfig definitions
|
|
25
|
+
├── pipelines/
|
|
26
|
+
│ ├── load_lifecycle.yaml # hexDAG pipeline per entity
|
|
27
|
+
│ └── carrier_lifecycle.yaml
|
|
28
|
+
└── services/
|
|
29
|
+
├── load_service.py # @tool/@step CRUD (implemented) + domain stubs
|
|
30
|
+
└── carrier_service.py
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## What it generates
|
|
34
|
+
|
|
35
|
+
- **YAML Pipelines** -- entity lifecycle workflows using hexDAG nodes
|
|
36
|
+
- **Python Services** -- `@tool`/`@step` CRUD methods (implemented) + domain-specific stubs (`NotImplementedError`) for the builder agent to fill
|
|
37
|
+
- **Django Models** -- ORM models with fields, relationships, state choices
|
|
38
|
+
- **System Manifest** -- `kind: System` with state machines, `on_enter` process mappings, terminal states
|
|
39
|
+
- **State Machines** -- `StateMachineConfig` definitions with validated transitions
|
|
40
|
+
- **Django Migrations** -- auto-generated from profile diffs (CreateModel, AddField, AlterField, etc.)
|
|
41
|
+
|
|
42
|
+
## MCP + Claude Code
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install hexdag-forge
|
|
46
|
+
hexdag-forge mcp serve
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Skills
|
|
50
|
+
|
|
51
|
+
- `/forge "I run a freight brokerage"` -- generate a new system
|
|
52
|
+
- `/forge:entity "add Carrier with mc_number, score"` -- add/modify entity (with automigration)
|
|
53
|
+
- `/forge:implement "fill in negotiate_rate"` -- implement a service stub
|
|
54
|
+
- `/forge:test` -- validate generated files
|
|
55
|
+
|
|
56
|
+
### MCP Tools
|
|
57
|
+
|
|
58
|
+
| Tool | Description |
|
|
59
|
+
|------|-------------|
|
|
60
|
+
| `forge_profile_schema` | Get the BusinessProfile JSON schema |
|
|
61
|
+
| `forge_generate` | Generate a new system from a BusinessProfile JSON |
|
|
62
|
+
| `forge_migrate` | Update an existing system -- diffs profiles, regenerates, writes migration |
|
|
63
|
+
| `forge_get_profile` | Read the profile from a generated system |
|
|
64
|
+
| `forge_validate` | Validate generated files (syntax, structure) |
|
|
65
|
+
| `forge_list_stubs` | List NotImplementedError methods in services |
|
|
66
|
+
| `forge_implement_stub` | Replace a stub with implementation code |
|
|
67
|
+
|
|
68
|
+
## CLI
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Generate from a profile JSON file
|
|
72
|
+
hexdag-forge generate profile.json --output ./my-erp/
|
|
73
|
+
|
|
74
|
+
# Validate generated files
|
|
75
|
+
hexdag-forge validate ./my-erp/
|
|
76
|
+
|
|
77
|
+
# Start MCP server for Claude Code
|
|
78
|
+
hexdag-forge mcp serve
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Automigration
|
|
82
|
+
|
|
83
|
+
When you modify the ontology (add entities, change fields, rename states), forge automatically generates Django migration files:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
# Initial generation creates 0001_auto.py with CreateModel for each entity
|
|
87
|
+
/forge "freight brokerage with loads and carriers"
|
|
88
|
+
→ migrations/0001_auto.py (CreateModel: Load, Carrier)
|
|
89
|
+
|
|
90
|
+
# Adding a field via /forge:entity creates 0002_auto.py
|
|
91
|
+
/forge:entity "add weight_limit field to carrier"
|
|
92
|
+
→ migrations/0002_auto.py (AddField: carrier.weight_limit)
|
|
93
|
+
|
|
94
|
+
# Changing states creates another migration
|
|
95
|
+
/forge:entity "add SUSPENDED state to carrier"
|
|
96
|
+
→ migrations/0003_auto.py (AlterField: carrier.status choices)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Supported operations: `CreateModel`, `DeleteModel`, `AddField`, `RemoveField`, `AlterField` (type changes, option changes, state changes), `ForeignKey` additions/removals.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
Apache 2.0
|
|
@@ -19,17 +19,17 @@ app.add_typer(mcp_app, name="mcp")
|
|
|
19
19
|
|
|
20
20
|
@app.command()
|
|
21
21
|
def generate(
|
|
22
|
-
|
|
22
|
+
profile: Annotated[Path, typer.Argument(help="Path to BusinessProfile JSON file")],
|
|
23
23
|
output: Annotated[
|
|
24
24
|
Path, typer.Option(help="Output directory for generated files")
|
|
25
25
|
] = "./generated",
|
|
26
26
|
) -> None:
|
|
27
|
-
"""Generate an operational system from a
|
|
27
|
+
"""Generate an operational system from a BusinessProfile JSON file."""
|
|
28
|
+
from hexdag_forge.domain.business_profile import BusinessProfile
|
|
28
29
|
from hexdag_forge.generator.assembler import assemble
|
|
29
|
-
from hexdag_forge.generator.business_analyzer import create_default_profile
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
result = assemble(
|
|
31
|
+
bp = BusinessProfile.model_validate_json(profile.read_text())
|
|
32
|
+
result = assemble(bp, output)
|
|
33
33
|
typer.echo(f"Generated system in {output}/")
|
|
34
34
|
typer.echo(f" Entities: {', '.join(e.name for e in result.profile.entities)}")
|
|
35
35
|
typer.echo(f" Pipelines: {len(result.pipelines)} files")
|
|
@@ -8,13 +8,25 @@ from hexdag_forge.domain.business_profile import (
|
|
|
8
8
|
)
|
|
9
9
|
from hexdag_forge.domain.enums import FieldType, RelationshipKind
|
|
10
10
|
from hexdag_forge.domain.generated_system import GeneratedSystem
|
|
11
|
+
from hexdag_forge.domain.profile_diff import (
|
|
12
|
+
EntityChange,
|
|
13
|
+
FieldChange,
|
|
14
|
+
ProfileDiff,
|
|
15
|
+
RelationshipChange,
|
|
16
|
+
StateChange,
|
|
17
|
+
)
|
|
11
18
|
|
|
12
19
|
__all__ = [
|
|
13
20
|
"BusinessProfile",
|
|
21
|
+
"EntityChange",
|
|
14
22
|
"EntitySpec",
|
|
23
|
+
"FieldChange",
|
|
15
24
|
"FieldSpec",
|
|
16
25
|
"FieldType",
|
|
17
26
|
"GeneratedSystem",
|
|
27
|
+
"ProfileDiff",
|
|
28
|
+
"RelationshipChange",
|
|
18
29
|
"RelationshipKind",
|
|
19
30
|
"RelationshipSpec",
|
|
31
|
+
"StateChange",
|
|
20
32
|
]
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import re
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
|
-
from pydantic import BaseModel, field_validator
|
|
8
|
+
from pydantic import BaseModel, field_validator, model_validator
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class FieldSpec(BaseModel):
|
|
@@ -98,3 +99,27 @@ class BusinessProfile(BaseModel):
|
|
|
98
99
|
entities: list[EntitySpec]
|
|
99
100
|
workflows: list[str] = []
|
|
100
101
|
modules: list[str] = []
|
|
102
|
+
app_label: str = ""
|
|
103
|
+
|
|
104
|
+
@model_validator(mode="before")
|
|
105
|
+
@classmethod
|
|
106
|
+
def derive_app_label(cls, data: Any) -> Any:
|
|
107
|
+
"""Derive app_label from business_name if not provided."""
|
|
108
|
+
if isinstance(data, dict) and not data.get("app_label"):
|
|
109
|
+
name = data.get("business_name", "app")
|
|
110
|
+
# "Freight Brokerage" → "freight_brokerage"
|
|
111
|
+
label = re.sub(r"[^a-zA-Z0-9]+", "_", name).strip("_").lower()
|
|
112
|
+
data["app_label"] = label
|
|
113
|
+
return data
|
|
114
|
+
|
|
115
|
+
@field_validator("app_label")
|
|
116
|
+
@classmethod
|
|
117
|
+
def validate_app_label(cls, v: str) -> str:
|
|
118
|
+
"""Ensure app_label is a valid lowercase Python identifier."""
|
|
119
|
+
if not v.isidentifier():
|
|
120
|
+
msg = f"app_label must be a valid Python identifier, got: {v!r}"
|
|
121
|
+
raise ValueError(msg)
|
|
122
|
+
if v != v.lower():
|
|
123
|
+
msg = f"app_label must be lowercase, got: {v!r}"
|
|
124
|
+
raise ValueError(msg)
|
|
125
|
+
return v
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Domain models for profile diffing — captures schema changes between two BusinessProfiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from hexdag_forge.domain.business_profile import ( # noqa: TC001
|
|
8
|
+
EntitySpec,
|
|
9
|
+
FieldSpec,
|
|
10
|
+
RelationshipSpec,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FieldChange(BaseModel):
|
|
15
|
+
"""A single field-level change within an entity."""
|
|
16
|
+
|
|
17
|
+
entity_name: str
|
|
18
|
+
field_name: str
|
|
19
|
+
change_type: str # "add" | "remove" | "alter_type" | "alter_options"
|
|
20
|
+
old_field: FieldSpec | None = None
|
|
21
|
+
new_field: FieldSpec | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RelationshipChange(BaseModel):
|
|
25
|
+
"""A relationship added or removed."""
|
|
26
|
+
|
|
27
|
+
entity_name: str
|
|
28
|
+
field_name: str
|
|
29
|
+
change_type: str # "add" | "remove"
|
|
30
|
+
old_rel: RelationshipSpec | None = None
|
|
31
|
+
new_rel: RelationshipSpec | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StateChange(BaseModel):
|
|
35
|
+
"""Change in an entity's status choices or initial state."""
|
|
36
|
+
|
|
37
|
+
entity_name: str
|
|
38
|
+
old_states: list[str]
|
|
39
|
+
new_states: list[str]
|
|
40
|
+
old_initial: str
|
|
41
|
+
new_initial: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class EntityChange(BaseModel):
|
|
45
|
+
"""An entire entity added or removed."""
|
|
46
|
+
|
|
47
|
+
change_type: str # "add" | "remove"
|
|
48
|
+
entity: EntitySpec
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ProfileDiff(BaseModel):
|
|
52
|
+
"""Complete diff between two BusinessProfile versions."""
|
|
53
|
+
|
|
54
|
+
entity_changes: list[EntityChange] = []
|
|
55
|
+
field_changes: list[FieldChange] = []
|
|
56
|
+
relationship_changes: list[RelationshipChange] = []
|
|
57
|
+
state_changes: list[StateChange] = []
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def has_changes(self) -> bool:
|
|
61
|
+
"""Return True if any schema changes were detected."""
|
|
62
|
+
return bool(
|
|
63
|
+
self.entity_changes
|
|
64
|
+
or self.field_changes
|
|
65
|
+
or self.relationship_changes
|
|
66
|
+
or self.state_changes
|
|
67
|
+
)
|
|
@@ -57,8 +57,29 @@ def assemble(profile: BusinessProfile, output_dir: Path) -> GeneratedSystem:
|
|
|
57
57
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
58
|
file_path.write_text(content)
|
|
59
59
|
|
|
60
|
-
# Write
|
|
61
|
-
|
|
60
|
+
# Write app __init__.py
|
|
61
|
+
app_dir = output_dir / profile.app_label
|
|
62
|
+
app_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
(app_dir / "__init__.py").write_text("")
|
|
64
|
+
|
|
65
|
+
# Write migrations dir + initial migration
|
|
66
|
+
migrations_dir = app_dir / "migrations"
|
|
67
|
+
migrations_dir.mkdir(exist_ok=True)
|
|
68
|
+
(migrations_dir / "__init__.py").write_text("")
|
|
69
|
+
|
|
70
|
+
from hexdag_forge.generator.migration_generator import generate_migration
|
|
71
|
+
|
|
72
|
+
empty_profile = BusinessProfile(
|
|
73
|
+
business_name=profile.business_name,
|
|
74
|
+
business_type=profile.business_type,
|
|
75
|
+
description=profile.description,
|
|
76
|
+
entities=[],
|
|
77
|
+
app_label=profile.app_label,
|
|
78
|
+
)
|
|
79
|
+
result = generate_migration(empty_profile, profile, migrations_dir)
|
|
80
|
+
if result is not None:
|
|
81
|
+
filename, migration_content = result
|
|
82
|
+
(migrations_dir / filename).write_text(migration_content)
|
|
62
83
|
|
|
63
84
|
# Write profile.json for refinement
|
|
64
85
|
(output_dir / "profile.json").write_text(profile.model_dump_json(indent=2))
|
|
@@ -67,7 +88,9 @@ def assemble(profile: BusinessProfile, output_dir: Path) -> GeneratedSystem:
|
|
|
67
88
|
profile=profile,
|
|
68
89
|
pipelines=pipelines,
|
|
69
90
|
services=services,
|
|
70
|
-
models_source={
|
|
91
|
+
models_source={
|
|
92
|
+
f"{profile.app_label}/models.py": django_files[f"{profile.app_label}/models.py"]
|
|
93
|
+
},
|
|
71
94
|
state_machines={e.name: e.transitions for e in profile.entities},
|
|
72
95
|
system_yaml=system_yaml,
|
|
73
96
|
)
|
|
@@ -83,7 +106,7 @@ def validate_system(output_dir: Path) -> list[str]:
|
|
|
83
106
|
output_dir = Path(output_dir)
|
|
84
107
|
|
|
85
108
|
# Check required files exist
|
|
86
|
-
required = ["system.yaml", "profile.json", "settings.py", "manage.py"]
|
|
109
|
+
required = ["system.yaml", "profile.json", "settings.py", "manage.py", "views.py", "forms.py"]
|
|
87
110
|
errors.extend(f"Missing required file: {f}" for f in required if not (output_dir / f).exists())
|
|
88
111
|
|
|
89
112
|
# Check pipelines directory
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Profile diffing engine — compares two BusinessProfiles and produces a ProfileDiff."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from hexdag_forge.domain.profile_diff import (
|
|
8
|
+
EntityChange,
|
|
9
|
+
FieldChange,
|
|
10
|
+
ProfileDiff,
|
|
11
|
+
RelationshipChange,
|
|
12
|
+
StateChange,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from hexdag_forge.domain.business_profile import BusinessProfile, EntitySpec
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def diff_profiles(old: BusinessProfile, new: BusinessProfile) -> ProfileDiff:
|
|
20
|
+
"""Compare two BusinessProfiles and return a structured diff.
|
|
21
|
+
|
|
22
|
+
Entity matching is by ``name`` (the Python identifier).
|
|
23
|
+
Field matching is by ``field.name``. Relationship matching by ``field_name``.
|
|
24
|
+
Renames are treated as remove + add.
|
|
25
|
+
"""
|
|
26
|
+
old_entities = {e.name: e for e in old.entities}
|
|
27
|
+
new_entities = {e.name: e for e in new.entities}
|
|
28
|
+
|
|
29
|
+
# Added / removed entities
|
|
30
|
+
entity_changes = [
|
|
31
|
+
EntityChange(change_type="add", entity=new_entities[n])
|
|
32
|
+
for n in new_entities
|
|
33
|
+
if n not in old_entities
|
|
34
|
+
] + [
|
|
35
|
+
EntityChange(change_type="remove", entity=old_entities[n])
|
|
36
|
+
for n in old_entities
|
|
37
|
+
if n not in new_entities
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# Compare internals for entities present in both
|
|
41
|
+
field_changes: list[FieldChange] = []
|
|
42
|
+
relationship_changes: list[RelationshipChange] = []
|
|
43
|
+
state_changes: list[StateChange] = []
|
|
44
|
+
|
|
45
|
+
for name in old_entities:
|
|
46
|
+
if name in new_entities:
|
|
47
|
+
old_e = old_entities[name]
|
|
48
|
+
new_e = new_entities[name]
|
|
49
|
+
field_changes.extend(_diff_fields(name, old_e, new_e))
|
|
50
|
+
relationship_changes.extend(_diff_relationships(name, old_e, new_e))
|
|
51
|
+
sc = _diff_states(name, old_e, new_e)
|
|
52
|
+
if sc is not None:
|
|
53
|
+
state_changes.append(sc)
|
|
54
|
+
|
|
55
|
+
return ProfileDiff(
|
|
56
|
+
entity_changes=entity_changes,
|
|
57
|
+
field_changes=field_changes,
|
|
58
|
+
relationship_changes=relationship_changes,
|
|
59
|
+
state_changes=state_changes,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _diff_fields(entity_name: str, old: EntitySpec, new: EntitySpec) -> list[FieldChange]:
|
|
64
|
+
"""Compare fields between two versions of the same entity."""
|
|
65
|
+
old_fields = {f.name: f for f in old.fields}
|
|
66
|
+
new_fields = {f.name: f for f in new.fields}
|
|
67
|
+
changes: list[FieldChange] = []
|
|
68
|
+
|
|
69
|
+
for fname in new_fields:
|
|
70
|
+
if fname not in old_fields:
|
|
71
|
+
changes.append(
|
|
72
|
+
FieldChange(
|
|
73
|
+
entity_name=entity_name,
|
|
74
|
+
field_name=fname,
|
|
75
|
+
change_type="add",
|
|
76
|
+
new_field=new_fields[fname],
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
old_f = old_fields[fname]
|
|
81
|
+
new_f = new_fields[fname]
|
|
82
|
+
if old_f.type != new_f.type:
|
|
83
|
+
changes.append(
|
|
84
|
+
FieldChange(
|
|
85
|
+
entity_name=entity_name,
|
|
86
|
+
field_name=fname,
|
|
87
|
+
change_type="alter_type",
|
|
88
|
+
old_field=old_f,
|
|
89
|
+
new_field=new_f,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
elif old_f.required != new_f.required or old_f.default != new_f.default:
|
|
93
|
+
changes.append(
|
|
94
|
+
FieldChange(
|
|
95
|
+
entity_name=entity_name,
|
|
96
|
+
field_name=fname,
|
|
97
|
+
change_type="alter_options",
|
|
98
|
+
old_field=old_f,
|
|
99
|
+
new_field=new_f,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
changes.extend(
|
|
104
|
+
FieldChange(
|
|
105
|
+
entity_name=entity_name,
|
|
106
|
+
field_name=fname,
|
|
107
|
+
change_type="remove",
|
|
108
|
+
old_field=old_fields[fname],
|
|
109
|
+
)
|
|
110
|
+
for fname in old_fields
|
|
111
|
+
if fname not in new_fields
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return changes
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _diff_relationships(
|
|
118
|
+
entity_name: str,
|
|
119
|
+
old: EntitySpec,
|
|
120
|
+
new: EntitySpec,
|
|
121
|
+
) -> list[RelationshipChange]:
|
|
122
|
+
"""Compare relationships between two versions of the same entity."""
|
|
123
|
+
old_rels = {r.field_name: r for r in old.relationships}
|
|
124
|
+
new_rels = {r.field_name: r for r in new.relationships}
|
|
125
|
+
|
|
126
|
+
return [
|
|
127
|
+
RelationshipChange(
|
|
128
|
+
entity_name=entity_name,
|
|
129
|
+
field_name=fname,
|
|
130
|
+
change_type="add",
|
|
131
|
+
new_rel=new_rels[fname],
|
|
132
|
+
)
|
|
133
|
+
for fname in new_rels
|
|
134
|
+
if fname not in old_rels
|
|
135
|
+
] + [
|
|
136
|
+
RelationshipChange(
|
|
137
|
+
entity_name=entity_name,
|
|
138
|
+
field_name=fname,
|
|
139
|
+
change_type="remove",
|
|
140
|
+
old_rel=old_rels[fname],
|
|
141
|
+
)
|
|
142
|
+
for fname in old_rels
|
|
143
|
+
if fname not in new_rels
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _diff_states(entity_name: str, old: EntitySpec, new: EntitySpec) -> StateChange | None:
|
|
148
|
+
"""Compare states and initial_state between two versions of the same entity."""
|
|
149
|
+
if old.states == new.states and old.initial_state == new.initial_state:
|
|
150
|
+
return None
|
|
151
|
+
return StateChange(
|
|
152
|
+
entity_name=entity_name,
|
|
153
|
+
old_states=old.states,
|
|
154
|
+
new_states=new.states,
|
|
155
|
+
old_initial=old.initial_state,
|
|
156
|
+
new_initial=new.initial_state,
|
|
157
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Generate Django project files from a BusinessProfile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from hexdag_forge.generator.template_engine import render_html_template, render_template
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from hexdag_forge.domain.business_profile import BusinessProfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_django(profile: BusinessProfile) -> dict[str, str]:
|
|
14
|
+
"""Generate Django models, views, forms, templates, and project config.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Dict of relative path -> file content.
|
|
18
|
+
"""
|
|
19
|
+
app = profile.app_label
|
|
20
|
+
files: dict[str, str] = {}
|
|
21
|
+
|
|
22
|
+
# ── Python files (standard Jinja2 delimiters) ──────────
|
|
23
|
+
files[f"{app}/models.py"] = render_template("django_model.py.j2", profile=profile)
|
|
24
|
+
files[f"{app}/apps.py"] = render_template("django_apps.py.j2", profile=profile)
|
|
25
|
+
files[f"{app}/admin.py"] = render_template("django_admin.py.j2", profile=profile)
|
|
26
|
+
files["settings.py"] = render_template("django_settings.py.j2", profile=profile)
|
|
27
|
+
files["urls.py"] = render_template("django_urls.py.j2", profile=profile)
|
|
28
|
+
files["manage.py"] = render_template("django_manage.py.j2", profile=profile)
|
|
29
|
+
files["state_machines.py"] = render_template("state_machines.py.j2", profile=profile)
|
|
30
|
+
files["views.py"] = render_template("django_views.py.j2", profile=profile)
|
|
31
|
+
files["forms.py"] = render_template("django_forms.py.j2", profile=profile)
|
|
32
|
+
|
|
33
|
+
# ── HTML templates (alt delimiters — outputs Django template syntax) ──
|
|
34
|
+
files["templates/base.html"] = render_html_template("django_base.html.j2", profile=profile)
|
|
35
|
+
|
|
36
|
+
# Auth
|
|
37
|
+
files["templates/registration/login.html"] = render_html_template(
|
|
38
|
+
"django_login.html.j2", profile=profile
|
|
39
|
+
)
|
|
40
|
+
files["templates/registration/register.html"] = render_html_template(
|
|
41
|
+
"django_register.html.j2", profile=profile
|
|
42
|
+
)
|
|
43
|
+
files["templates/registration/logged_out.html"] = render_html_template(
|
|
44
|
+
"django_logged_out.html.j2", profile=profile
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Per-entity CRUD templates
|
|
48
|
+
for entity in profile.entities:
|
|
49
|
+
prefix = f"templates/{entity.name}"
|
|
50
|
+
ctx = {"entity": entity, "profile": profile}
|
|
51
|
+
files[f"{prefix}/list.html"] = render_html_template("django_entity_list.html.j2", **ctx)
|
|
52
|
+
files[f"{prefix}/_list_rows.html"] = render_html_template(
|
|
53
|
+
"django_entity_list_partial.html.j2", **ctx
|
|
54
|
+
)
|
|
55
|
+
files[f"{prefix}/detail.html"] = render_html_template("django_entity_detail.html.j2", **ctx)
|
|
56
|
+
files[f"{prefix}/_detail_content.html"] = render_html_template(
|
|
57
|
+
"django_entity_detail_partial.html.j2", **ctx
|
|
58
|
+
)
|
|
59
|
+
files[f"{prefix}/form.html"] = render_html_template("django_entity_form.html.j2", **ctx)
|
|
60
|
+
files[f"{prefix}/confirm_delete.html"] = render_html_template(
|
|
61
|
+
"django_entity_confirm_delete.html.j2", **ctx
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Chat
|
|
65
|
+
files["templates/chat/chat.html"] = render_html_template("django_chat.html.j2", profile=profile)
|
|
66
|
+
files["templates/chat/conversations.html"] = render_html_template(
|
|
67
|
+
"django_chat_conversations.html.j2", profile=profile
|
|
68
|
+
)
|
|
69
|
+
files["templates/chat/_messages.html"] = render_html_template(
|
|
70
|
+
"django_chat_messages_partial.html.j2", profile=profile
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return files
|