hexdag-forge 0.2.0.dev2__tar.gz → 0.2.0.dev4__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.
Files changed (77) hide show
  1. hexdag_forge-0.2.0.dev4/PKG-INFO +137 -0
  2. hexdag_forge-0.2.0.dev4/README.md +103 -0
  3. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/cli.py +5 -5
  4. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/domain/__init__.py +12 -0
  5. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/domain/business_profile.py +26 -1
  6. hexdag_forge-0.2.0.dev4/hexdag_forge/domain/profile_diff.py +67 -0
  7. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/generator/assembler.py +45 -5
  8. hexdag_forge-0.2.0.dev4/hexdag_forge/generator/diff.py +157 -0
  9. hexdag_forge-0.2.0.dev4/hexdag_forge/generator/django_generator.py +108 -0
  10. hexdag_forge-0.2.0.dev4/hexdag_forge/generator/field_mapping.py +93 -0
  11. hexdag_forge-0.2.0.dev4/hexdag_forge/generator/migration_generator.py +249 -0
  12. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/generator/pipeline_generator.py +4 -0
  13. hexdag_forge-0.2.0.dev4/hexdag_forge/generator/template_engine.py +209 -0
  14. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/mcp/server.py +89 -34
  15. hexdag_forge-0.2.0.dev4/hexdag_forge/skills/forge.md +57 -0
  16. hexdag_forge-0.2.0.dev4/hexdag_forge/skills/forge_entity.md +37 -0
  17. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/skills/forge_implement.md +2 -2
  18. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/skills/forge_test.md +1 -1
  19. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/chat_pipeline.yaml.j2 +33 -0
  20. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/templates/django_admin.py.j2 +15 -1
  21. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_apps.py.j2 +12 -0
  22. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_base.html.j2 +149 -0
  23. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_chat.html.j2 +43 -0
  24. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_chat_agent.py.j2 +97 -0
  25. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_chat_conversations.html.j2 +29 -0
  26. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_chat_messages_partial.html.j2 +8 -0
  27. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_dashboard.html.j2 +47 -0
  28. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_entity_confirm_delete.html.j2 +15 -0
  29. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_entity_detail.html.j2 +14 -0
  30. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_entity_detail_partial.html.j2 +43 -0
  31. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_entity_form.html.j2 +15 -0
  32. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_entity_form_modal.html.j2 +13 -0
  33. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_entity_list.html.j2 +51 -0
  34. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_entity_list_partial.html.j2 +15 -0
  35. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_forms.py.j2 +32 -0
  36. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_logged_out.html.j2 +9 -0
  37. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_login.html.j2 +13 -0
  38. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_migration.py.j2 +24 -0
  39. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/templates/django_model.py.j2 +35 -1
  40. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_register.html.j2 +13 -0
  41. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/templates/django_settings.py.j2 +12 -1
  42. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_templatetags.py.j2 +43 -0
  43. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_urls.py.j2 +54 -0
  44. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_views.py.j2 +269 -0
  45. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/templates/service.py.j2 +1 -1
  46. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/pyproject.toml +1 -1
  47. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/tests/test_domain.py +48 -0
  48. hexdag_forge-0.2.0.dev4/tests/test_generator/test_assembler.py +386 -0
  49. hexdag_forge-0.2.0.dev4/tests/test_generator/test_diff.py +200 -0
  50. hexdag_forge-0.2.0.dev4/tests/test_generator/test_migration_generator.py +187 -0
  51. hexdag_forge-0.2.0.dev2/PKG-INFO +0 -101
  52. hexdag_forge-0.2.0.dev2/README.md +0 -67
  53. hexdag_forge-0.2.0.dev2/hexdag_forge/generator/business_analyzer.py +0 -161
  54. hexdag_forge-0.2.0.dev2/hexdag_forge/generator/django_generator.py +0 -27
  55. hexdag_forge-0.2.0.dev2/hexdag_forge/generator/template_engine.py +0 -27
  56. hexdag_forge-0.2.0.dev2/hexdag_forge/skills/forge.md +0 -34
  57. hexdag_forge-0.2.0.dev2/hexdag_forge/skills/forge_entity.md +0 -25
  58. hexdag_forge-0.2.0.dev2/hexdag_forge/templates/django_urls.py.j2 +0 -11
  59. hexdag_forge-0.2.0.dev2/tests/test_generator/test_assembler.py +0 -166
  60. hexdag_forge-0.2.0.dev2/tests/test_generator/test_business_analyzer.py +0 -35
  61. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/.gitignore +0 -0
  62. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/LICENSE +0 -0
  63. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/__init__.py +0 -0
  64. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/domain/enums.py +0 -0
  65. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/domain/generated_system.py +0 -0
  66. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/generator/__init__.py +0 -0
  67. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/generator/entity_generator.py +0 -0
  68. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/generator/service_generator.py +0 -0
  69. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/generator/system_generator.py +0 -0
  70. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/mcp/__init__.py +0 -0
  71. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/mcp/__main__.py +0 -0
  72. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/templates/django_manage.py.j2 +0 -0
  73. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/templates/pipeline.yaml.j2 +0 -0
  74. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/templates/state_machines.py.j2 +0 -0
  75. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/hexdag_forge/templates/system.yaml.j2 +0 -0
  76. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/tests/__init__.py +0 -0
  77. {hexdag_forge-0.2.0.dev2 → hexdag_forge-0.2.0.dev4}/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.dev4
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
- description: Annotated[str, typer.Argument(help="Business description")],
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 business description."""
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
- profile = create_default_profile(description)
32
- result = assemble(profile, output)
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
+ )
@@ -18,12 +18,19 @@ from hexdag_forge.generator.service_generator import generate_services
18
18
  from hexdag_forge.generator.system_generator import generate_system
19
19
 
20
20
 
21
- def assemble(profile: BusinessProfile, output_dir: Path) -> GeneratedSystem:
21
+ def assemble(
22
+ profile: BusinessProfile,
23
+ output_dir: Path,
24
+ *,
25
+ skip_migration: bool = False,
26
+ ) -> GeneratedSystem:
22
27
  """Generate all files from a BusinessProfile and write to output_dir.
23
28
 
24
29
  Args:
25
30
  profile: The business profile to generate from.
26
31
  output_dir: Directory to write generated files to.
32
+ skip_migration: If True, skip migration generation (used when the
33
+ caller has already generated the migration, e.g. ``forge_migrate``).
27
34
 
28
35
  Returns:
29
36
  GeneratedSystem with metadata about what was generated.
@@ -57,8 +64,39 @@ def assemble(profile: BusinessProfile, output_dir: Path) -> GeneratedSystem:
57
64
  file_path.parent.mkdir(parents=True, exist_ok=True)
58
65
  file_path.write_text(content)
59
66
 
60
- # Write models/__init__.py
61
- (output_dir / "models" / "__init__.py").write_text("")
67
+ # Write app __init__.py
68
+ app_dir = output_dir / profile.app_label
69
+ app_dir.mkdir(parents=True, exist_ok=True)
70
+ (app_dir / "__init__.py").write_text("")
71
+
72
+ # Write migrations dir + initial migration
73
+ migrations_dir = app_dir / "migrations"
74
+ migrations_dir.mkdir(exist_ok=True)
75
+ (migrations_dir / "__init__.py").write_text("")
76
+
77
+ if not skip_migration:
78
+ from hexdag_forge.generator.migration_generator import generate_migration
79
+
80
+ # Use existing profile.json (from a previous run) as the old state so
81
+ # that diff_profiles produces incremental operations (AddField, etc.)
82
+ # instead of CreateModel for every entity. On the first run the file
83
+ # does not exist yet, so we fall back to an empty profile.
84
+ profile_path = output_dir / "profile.json"
85
+ if profile_path.exists():
86
+ old_profile = BusinessProfile.model_validate_json(profile_path.read_text())
87
+ else:
88
+ old_profile = BusinessProfile(
89
+ business_name=profile.business_name,
90
+ business_type=profile.business_type,
91
+ description=profile.description,
92
+ entities=[],
93
+ app_label=profile.app_label,
94
+ )
95
+
96
+ result = generate_migration(old_profile, profile, migrations_dir)
97
+ if result is not None:
98
+ filename, migration_content = result
99
+ (migrations_dir / filename).write_text(migration_content)
62
100
 
63
101
  # Write profile.json for refinement
64
102
  (output_dir / "profile.json").write_text(profile.model_dump_json(indent=2))
@@ -67,7 +105,9 @@ def assemble(profile: BusinessProfile, output_dir: Path) -> GeneratedSystem:
67
105
  profile=profile,
68
106
  pipelines=pipelines,
69
107
  services=services,
70
- models_source={"models/models.py": django_files["models/models.py"]},
108
+ models_source={
109
+ f"{profile.app_label}/models.py": django_files[f"{profile.app_label}/models.py"]
110
+ },
71
111
  state_machines={e.name: e.transitions for e in profile.entities},
72
112
  system_yaml=system_yaml,
73
113
  )
@@ -83,7 +123,7 @@ def validate_system(output_dir: Path) -> list[str]:
83
123
  output_dir = Path(output_dir)
84
124
 
85
125
  # Check required files exist
86
- required = ["system.yaml", "profile.json", "settings.py", "manage.py"]
126
+ required = ["system.yaml", "profile.json", "settings.py", "manage.py", "views.py", "forms.py"]
87
127
  errors.extend(f"Missing required file: {f}" for f in required if not (output_dir / f).exists())
88
128
 
89
129
  # 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
+ )