diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63cdecf..039cdc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -674,6 +674,62 @@ jobs: # fixed there could survive in the template it came from — and did: every # new project started with 4 Spectral errors and an invalid AsyncAPI root # `tags` key, because nothing ever linted the scaffold. + - name: add-component seeds once and refuses what it cannot seed (offline, #177) + run: | + set -uo pipefail + CLI=/tmp/ci/bin/specfuse-authoring + fail() { echo "::error::$1" >&2; exit 1; } + R=$(mktemp -d) || fail "mktemp failed" + cp -R /tmp/smoke "$R/specs" + cd "$R/specs" + PF=$(ls *-project.json) + + # A kebab-case name gives the generator an invalid Python root (clabonte/generator#2882). + out=$("$CLI" add-component python ai 2>&1) && fail "python seeded under a kebab-case project name" + grep -q "not a valid Python identifier" <<<"$out" || fail "unexpected message: $out" + # C# needs tenancy at this generator (clabonte/generator#2883). + out=$("$CLI" add-component csharp Backend 2>&1) && fail "csharp seeded without a tenancy block" + grep -q "needs a project-level \`tenancy\` block" <<<"$out" || fail "unexpected message: $out" + test ! -e "$R/Backend" || fail "a refused seed left a directory behind" + + python3 - <<'EOF' + import json, pathlib + p = next(pathlib.Path(".").glob("*-project.json")) + d = json.loads(p.read_text()) + d["name"] = "SmokeApp" + d["tenancy"] = {"tenant": {"entity": "Org", "foreignKey": "OrgId", "marker": "IOrgOwned"}} + p.write_text(json.dumps(d, indent=2)) + EOF + cp "$PF" before.json + "$CLI" add-component csharp Backend -n >/dev/null || fail "dry run failed" + cmp -s "$PF" before.json || fail "a dry run changed the project file" + test ! -e "$R/Backend" || fail "a dry run wrote files" + + "$CLI" add-component csharp Backend --no-generate || fail "csharp seed failed" + for f in SmokeApp.slnx Directory.Build.props .gitignore README.md CLAUDE.md \ + SmokeApp.Domain/SmokeApp.Domain.csproj SmokeApp.Infrastructure/SmokeApp.Infrastructure.csproj \ + SmokeApp.Api/SmokeApp.Api.csproj SmokeApp.Api/src/Program.cs \ + SmokeApp.Api/src/Utils/UserHelper.cs SmokeApp.Api/src/Filters/ExceptionFilter.cs; do + test -f "$R/Backend/$f" || fail "seed is missing $f" + done + test -d "$R/Backend/.git" || fail "the seeded repo was not git-initialised" + ! grep -rq "{{" "$R/Backend" --exclude-dir=.git || fail "an unrendered template token reached the seed" + python3 -c "import json,sys; g=[x['name'] for x in json.load(open(sys.argv[1]))['groups']]; sys.exit(0 if {'Domain','Infrastructure','Api'} <= set(g) else 1)" "$PF" \ + || fail "the C# groups were not added to the project file" + + # Seeding never overwrites, and a second seed's group names would clash. + out=$("$CLI" add-component csharp Backend 2>&1) && fail "seeded over an existing directory" + grep -q "already exists and is not empty" <<<"$out" || fail "unexpected message: $out" + out=$("$CLI" add-component csharp Backend2 2>&1) && fail "added clashing group names" + grep -q -- "--group-prefix" <<<"$out" || fail "unexpected message: $out" + + "$CLI" add-component python smoke-ai --no-generate || fail "python seed failed" + grep -q '^name = "sa-contracts"' "$R/smoke-ai/libs/contracts/pyproject.toml" || fail "python dist name is not derived like the generator's root" + ! grep -rq "{{" "$R/smoke-ai" --exclude-dir=.git || fail "an unrendered template token reached the seed" + # The kit never adopts seeded files: they are not in the scaffold manifest. + ! grep -q "Backend\|smoke-ai" .specfuse/authoring/.scaffold-manifest || fail "seeded files entered the scaffold manifest" + echo "==> add-component seeds once, and refuses what it cannot seed." + - name: Script ownership boundary — scripts/ is the project's, scripts/specfuse/ is ours run: | set -euo pipefail diff --git a/.github/workflows/example-regen.yml b/.github/workflows/example-regen.yml index 4eeef9f..fc48911 100644 --- a/.github/workflows/example-regen.yml +++ b/.github/workflows/example-regen.yml @@ -1561,6 +1561,85 @@ jobs: -p:GenSrc="$P/out-error-shape/HelloOrders.Domain/gen-src" echo "==> domainExceptions compiles against the kit's Error." + - name: A seeded C# component builds and serves (add-component drift guard, #177) + if: steps.token.outputs.have == 'true' + # `add-component csharp` seeds project files whose package references + # must match what the pinned generator's output compiles against. A pin + # that needs a new package fails HERE, not in an adopter's first build. + # hello-orders gains a `tenancy` block in this copy only: C# services + # need the event runtime, which needs tenancy (clabonte/generator#2883). + run: | + set -euo pipefail + R="$RUNNER_TEMP/component-cs" && mkdir -p "$R" + cp -R examples/hello-orders "$R/hello-orders" + cd "$R/hello-orders" + python3 - <<'EOF' + import json + p = "hello-orders-project.json" + d = json.load(open(p)) + d["tenancy"] = {"tenant": {"entity": "Tenant", "foreignKey": "TenantId", "marker": "IHelloTenantScoped"}} + json.dump(d, open(p, "w"), indent=2) + EOF + specfuse-authoring add-component csharp Backend --group-prefix Backend --no-generate + bash "$GITHUB_WORKSPACE/templates/project-init/scripts/specfuse/bundle-spec.sh" + bash "$GITHUB_WORKSPACE/templates/project-init/scripts/specfuse/bundle-async-spec.sh" + for g in BackendDomain BackendInfrastructure BackendApi; do + specfuse-authoring generate generate --group "$g" hello-orders-project.json + done + cd "$R/Backend" + dotnet build -nologo + # The composition root resolves: a list, a missing resource, a bad body. + dotnet run --project HelloOrders.Api --no-build --urls http://127.0.0.1:5180 > "$RUNNER_TEMP/api.log" 2>&1 & + API_PID=$! + for _ in $(seq 1 60); do curl -s -o /dev/null http://127.0.0.1:5180/ && break; sleep 1; done + T=11111111-1111-1111-1111-111111111111 + code() { curl -s -o /dev/null -w '%{http_code}' "$@"; } + list=$(code "http://127.0.0.1:5180/v1/tenants/$T/customers") + missing=$(code "http://127.0.0.1:5180/v1/customers/$T") + bad=$(code -X POST -H 'Content-Type: application/json' -d '{}' "http://127.0.0.1:5180/v1/tenants/$T/customers") + kill "$API_PID" || true + echo "list=$list missing=$missing bad=$bad" + if [[ "$list $missing $bad" != "200 404 400" ]]; then + cat "$RUNNER_TEMP/api.log" + echo "::error::the seeded C# component built but did not serve as expected (want 200 404 400)"; exit 1 + fi + echo "==> add-component csharp: seeds, builds, and serves on the pinned generator." + + - name: A seeded Python component installs, imports and passes its tests (add-component drift guard, #177) + if: steps.token.outputs.have == 'true' + # The project is renamed PascalCase in this copy only: the generator + # derives an invalid package root from kebab-case names + # (clabonte/generator#2882), and add-component refuses them. + run: | + set -euo pipefail + pip install --quiet uv + R="$RUNNER_TEMP/component-py" && mkdir -p "$R" + cp -R examples/hello-orders "$R/hello-orders" + cd "$R/hello-orders" + sed -i 's/"name": "hello-orders"/"name": "HelloOrders"/' hello-orders-project.json + specfuse-authoring add-component python hello-orders-ai --no-generate + bash "$GITHUB_WORKSPACE/templates/project-init/scripts/specfuse/bundle-spec.sh" + bash "$GITHUB_WORKSPACE/templates/project-init/scripts/specfuse/bundle-async-spec.sh" + for g in PythonContracts PythonCore; do + specfuse-authoring generate generate --group "$g" hello-orders-project.json + done + cd "$R/hello-orders-ai" + uv sync --all-packages + uv run python - <<'EOF' + import importlib, pkgutil, sys + failed = [] + for root in ("ho_contracts", "ho_core"): + for m in pkgutil.walk_packages(importlib.import_module(root).__path__, root + "."): + try: + importlib.import_module(m.name) + except Exception as e: + failed.append(f"{m.name}: {type(e).__name__}: {e}") + print("\n".join(failed) or "every generated module imports") + sys.exit(1 if failed else 0) + EOF + uv run pytest -q + echo "==> add-component python: seeds, installs, imports and tests on the pinned generator." + - name: Validate a fresh scaffold if: steps.token.outputs.have == 'true' run: | diff --git a/docs/add-component.md b/docs/add-component.md new file mode 100644 index 0000000..08692e6 --- /dev/null +++ b/docs/add-component.md @@ -0,0 +1,69 @@ + + +# Seeding a component repo + +`specfuse authoring add-component` creates a component repo **beside the spec repo**: a C# backend or a Python services workspace, ready to receive generated code. It also adds that component's groups to the project file. Run it from the spec repo: + +```bash +specfuse authoring add-component csharp Backend +specfuse authoring add-component python my-app-ai +``` + +| Option | Effect | +|---|---| +| `-n`, `--dry-run` | Print the groups it would add and the directory it would create. Write nothing. | +| `--group-prefix P` | Prefix the new group names, when the project file already has a `Domain` / `Api` / … group. | +| `--namespace N` | C# root namespace. Default: the project name in PascalCase (`hello-orders` → `HelloOrders`). | +| `--project FILE` | The project file, when the spec repo has more than one. | +| `--no-generate` | Seed only. Otherwise `scripts/specfuse/generate.sh` runs for the new groups if the project has it. | + +## Seeded once, then yours + +The command writes the host around the generated code: solution and project files, package manifests, the composition root, and the hand-written seams that generated code calls by name. **It writes them once.** They aren't in the scaffold manifest, `upgrade` never touches them, and the generator never writes them. They're project files, and they evolve with the project. + +It refuses a directory that already exists and isn't empty, and a group name the project file already uses. + +## C# + +``` +Backend/ +├── .slnx +├── Directory.Build.props compiles src/ and gen-src/ in every project +├── .Domain/.Domain.csproj +├── .Infrastructure/…csproj references Domain +├── .Api/…csproj Web SDK; references Domain and Infrastructure +│ └── src/ +│ ├── Program.cs wires the generated registrations +│ ├── Utils/UserHelper.cs claims → the caller the generated controllers expect +│ └── Filters/ExceptionFilter.cs generated domain exceptions → HTTP status + Error list +├── README.md, CLAUDE.md, .gitignore +``` + +It adds three groups (`Domain`, `Infrastructure`, `Api`) whose artifact sets compile together. After generating, `dotnet build` passes and `dotnet run --project .Api` serves on an in-memory database. Set `ConnectionStrings__Default` to use SQL Server. + +**Requires a `tenancy` block** in the project file (`handbooks/Project_File.md` §4.1). At the current generator, generated services always use the event runtime, and the event runtime requires tenancy ([clabonte/generator#2883](https://github.com/clabonte/generator/issues/2883)). + +## Python + +``` +my-app-ai/ +├── pyproject.toml uv workspace over libs/* +├── libs/contracts/pyproject.toml _contracts: models, enums, value objects, events, snapshots +├── libs/core/pyproject.toml _core: repositories, unit of work, outbox, emitters +├── README.md, CLAUDE.md, .gitignore +``` + +It adds two groups (`PythonContracts`, `PythonCore`). After generating, `uv sync --all-packages && uv run pytest` passes. + +The package roots must be the ones the generator derives from the project name, because generated code imports across the two libraries by those names. The derivation takes the capitals of the name (`HelloOrders` → `ho`). **A kebab-case project name currently yields an invalid root**, so the command refuses it ([clabonte/generator#2882](https://github.com/clabonte/generator/issues/2882)). Until that's fixed, set the project file's `name` to PascalCase. + +## Package versions and drift + +The seeded package references are the ones the kit's pinned generator compiles against. CI seeds both components from `examples/hello-orders`, generates them with the pinned generator, and then builds and runs them on every pin change. So a pin that needs a new package fails in the kit's CI before release. + +Projects that seeded earlier own their copies. When a generator upgrade needs a new package, its `compatibility.md` row says so, and the build names it. + +If the project pins its own generator (`.specfuse/generator.lock`), the command warns that the seed was verified with the kit's pin. diff --git a/specfuse/authoring/cli.py b/specfuse/authoring/cli.py index 4742328..3b58090 100644 --- a/specfuse/authoring/cli.py +++ b/specfuse/authoring/cli.py @@ -74,6 +74,18 @@ def main(argv: list[str] | None = None) -> int: p_refresh.add_argument("target", type=Path, help="Existing project directory.") p_refresh.add_argument("-n", "--dry-run", action="store_true", help=argparse.SUPPRESS) + p_comp = sub.add_parser( + "add-component", + help="Seed a component repo (C# backend or Python services) beside the spec repo, once.", + ) + p_comp.add_argument("language", choices=("csharp", "python"), help="Component language.") + p_comp.add_argument("directory", help="Directory to create beside the spec repo, e.g. Backend.") + p_comp.add_argument("--project", type=Path, help="Project file (default: the one *-project.json here).") + p_comp.add_argument("--namespace", help="C# root namespace (default: the project name in PascalCase).") + p_comp.add_argument("--group-prefix", default="", help="Prefix for the new group names, to avoid clashes.") + p_comp.add_argument("--no-generate", action="store_true", help="Seed only; don't run generate.sh.") + p_comp.add_argument("-n", "--dry-run", action="store_true", help="Report what would be created.") + p_gen = sub.add_parser( "generate", help="Run the pinned generator (downloads/verifies the jar on demand).", @@ -95,6 +107,12 @@ def main(argv: list[str] | None = None) -> int: ) from . import bootstrap return bootstrap.upgrade(ns.target, dry_run=ns.dry_run) + if ns.command == "add-component": + from . import component + return component.add_component( + ns.language, ns.directory, project_file=ns.project, namespace=ns.namespace, + group_prefix=ns.group_prefix, generate=not ns.no_generate, dry_run=ns.dry_run, + ) if ns.command == "generate": from . import generator return generator.generate(ns.args) diff --git a/specfuse/authoring/component.py b/specfuse/authoring/component.py new file mode 100644 index 0000000..4506ec7 --- /dev/null +++ b/specfuse/authoring/component.py @@ -0,0 +1,328 @@ +"""`add-component` — seed a component repo beside the spec repo, once. + +A component repo (a C# backend, a Python services workspace) receives generated +code from the spec repo through each group's `destination`. The generator +writes only the generated trees. The host around them (solution and project +files, package manifests, the composition root, the hand-written seams the +generated code calls by name) is project code. It is seeded here, ONCE, and is +the project's from then on: it is never in the scaffold manifest, and `upgrade` +never touches it. Seeding it from the generator instead would overwrite local +changes on every regen. + +What this does, from the spec repo: + + 1. creates `../` (refusing a non-empty one) and `git init`s it, + 2. renders `templates/components//` into it, + 3. appends the component's groups to the project file, + 4. runs `scripts/specfuse/generate.sh` for those groups when the project has it, + otherwise prints the commands to run. + +The seeded package versions are the ones the kit's pinned generator compiles +against. CI (`example-regen.yml`) seeds, generates and builds on every pin +change, so a stale template fails there rather than in an adopter's first build. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +from . import kit_root + +LANGUAGES = ("csharp", "python") + +# Artifact sets per C# layer: what the generated tree needs to compile together. +# The event runtime is always registered: at generator 0.19.0 the `service` +# artifact references IEventCollector whether or not the spec emits events +# (clabonte/generator#2883), and the runtime in turn needs the project-level +# `tenancy` block. +_CSHARP_DOMAIN = [ + "domainProject", "domainValidation", "domainExceptions", "valueObject", "entity", + "enum", "domainModel", "serviceInterface", "repositoryInterface", "service", + "asyncEvent", "tenancyMarker", "eventRuntime", "snapshot", "snapshotContext", +] +_CSHARP_INFRASTRUCTURE = [ + "entityTypeConfiguration", "valueObjectConverter", "dbContext", "repository", + "infrastructureProject", "eventRuntimeImpl", +] +_CSHARP_API = [ + "apiProject", "apiMapper", "apiModel", "apiController", "applicationServiceInterface", + "authorizationRuntime", +] + +_PYTHON_CONTRACTS = ["aiEntity", "enum", "valueObject", "snapshot", "snapshotContext", "event", "eventBuilder"] +_PYTHON_CORE = ["aiRepository", "eventOutbox", "defaultEventEmitter"] + +# Template files whose published name differs from their name in the kit. +# Dotfiles are stored without the dot so no packaging step can drop them. +_RENAMES = {"gitignore": ".gitignore"} + +_TOKEN = re.compile(r"\{\{(\w+)\}\}") + + +class ComponentError(RuntimeError): + pass + + +def render(text: str, values: dict) -> str: + """Replace each `{{Key}}`. An unknown key is a template bug and fails loudly + instead of rendering as empty text.""" + def token(m: re.Match) -> str: + key = m.group(1) + if key not in values: + raise ComponentError(f"template uses unknown key {{{{{key}}}}}") + return str(values[key]) + + return _TOKEN.sub(token, text) + + +def pascal_case(name: str) -> str: + """`hello-orders` → `HelloOrders`; an already PascalCase name is kept as is.""" + parts = [p for p in re.split(r"[^A-Za-z0-9]+", name) if p] + return "".join(p[:1].upper() + p[1:] for p in parts) + + +def python_abbreviation(project_name: str) -> str: + """The package-root prefix the generator derives for Python (`PythonProfile.projectAbbreviation`). + + The generator imports across Python libraries by `_contracts` / + `_core`, so the seeded packages must use exactly its derivation: + the lowercased capitals of the project name (`RestoManager` → `rm`), else + the name itself, snake-cased. + """ + caps = "".join(c.lower() for c in project_name if c.isupper()) + if caps: + return caps + return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", project_name).lower() + + +def _find_project_file(spec_dir: Path, explicit: Path | None) -> Path: + if explicit is not None: + if not explicit.is_file(): + raise ComponentError(f"project file not found: {explicit}") + return explicit + found = sorted(spec_dir.glob("*-project.json")) + if len(found) != 1: + raise ComponentError( + f"expected one -project.json in {spec_dir}, found {len(found)}; pass --project" + ) + return found[0] + + +def _generator_version(spec_dir: Path) -> tuple[str, str | None]: + """(kit-pinned version, project-pinned version or None).""" + kit = json.loads((kit_root() / "generator.lock").read_text())["generator"]["version"] + project_lock = spec_dir / ".specfuse" / "generator.lock" + project = None + if project_lock.is_file(): + try: + project = json.loads(project_lock.read_text()).get("generator", {}).get("version") + except json.JSONDecodeError: + project = None + return kit, project + + +def _groups(lang: str, config: dict, dir_name: str, values: dict, group_prefix: str) -> list[dict]: + dest = f"../{dir_name}/" + if lang == "csharp": + prefix = values["Prefix"] + return [ + { + "name": f"{group_prefix}Domain", + "language": "C#", + "basePackage": f"{prefix}.Domain", + "destination": dest, + "cleanGenerated": True, + "artifacts": list(_CSHARP_DOMAIN), + }, + { + "name": f"{group_prefix}Infrastructure", + "language": "C#", + "basePackage": f"{prefix}.Infrastructure", + "destination": dest, + "cleanGenerated": True, + "artifacts": list(_CSHARP_INFRASTRUCTURE), + }, + { + "name": f"{group_prefix}Api", + "language": "C#", + "basePackage": f"{prefix}.Api", + "destination": dest, + "cleanGenerated": True, + "artifacts": list(_CSHARP_API), + }, + ] + return [ + { + "name": f"{group_prefix}PythonContracts", + "language": "python", + "basePackage": values["ContractsPackage"], + "destination": f"{dest}libs/contracts/src", + "cleanGenerated": True, + "artifacts": list(_PYTHON_CONTRACTS), + }, + { + "name": f"{group_prefix}PythonCore", + "language": "python", + "basePackage": values["CorePackage"], + "destination": f"{dest}libs/core/src", + "cleanGenerated": True, + "artifacts": list(_PYTHON_CORE), + }, + ] + + +def _values(lang: str, config: dict, spec_dir: Path, dir_name: str, namespace: str | None) -> dict: + project_name = config.get("name") or "" + kit_version, project_version = _generator_version(spec_dir) + values = { + "Dir": dir_name, + "ProjectName": project_name, + "SpecRepo": spec_dir.name, + "GeneratorVersion": kit_version, + "ProjectGeneratorVersion": project_version or "", + } + if lang == "csharp": + prefix = namespace or pascal_case(project_name) + if not re.fullmatch(r"[A-Z][A-Za-z0-9]*(\.[A-Z][A-Za-z0-9]*)*", prefix or ""): + raise ComponentError( + f"cannot derive a C# namespace from project name {project_name!r}; pass --namespace" + ) + if not config.get("tenancy"): + raise ComponentError( + "a C# component needs a project-level `tenancy` block (handbooks/Project_File.md §4.1).\n" + " At this generator, generated services always use the event runtime, and the event\n" + " runtime requires tenancy, so a single-tenant project cannot compile them yet\n" + " (clabonte/generator#2883). Add a `tenancy` block naming your tenant entity, then rerun." + ) + values.update(Prefix=prefix) + else: + abbr = python_abbreviation(project_name) + if not abbr.isidentifier(): + raise ComponentError( + f"the generator derives the Python package root '{abbr}_contracts' from project " + f"name {project_name!r}, which is not a valid Python identifier.\n" + " Generated Python imports across its libraries by that derived name, so the\n" + " packages cannot be renamed here. Until the generator derives a valid root from\n" + " kebab-case names (clabonte/generator#2882), set the project file's `name` to PascalCase (e.g. " + f"{pascal_case(project_name)!r}), which yields " + f"'{python_abbreviation(pascal_case(project_name))}_contracts'." + ) + values.update( + ContractsPackage=f"{abbr}_contracts", + CorePackage=f"{abbr}_core", + ContractsDist=f"{abbr.replace('_', '-')}-contracts", + CoreDist=f"{abbr.replace('_', '-')}-core", + ) + return values + + +def _render_tree(src: Path, dest: Path, values: dict) -> list[Path]: + written: list[Path] = [] + for path in sorted(src.rglob("*")): + if not path.is_file() or "__pycache__" in path.parts: + continue + rel = path.relative_to(src).as_posix().replace("__Prefix__", str(values.get("Prefix", ""))) + parts = rel.split("/") + parts[-1] = _RENAMES.get(parts[-1], parts[-1]) + out = dest.joinpath(*parts) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(render(path.read_text(encoding="utf-8"), values), encoding="utf-8") + written.append(out) + return written + + +def add_component( + lang: str, + dir_name: str, + *, + spec_dir: Path | None = None, + project_file: Path | None = None, + namespace: str | None = None, + group_prefix: str = "", + generate: bool = True, + dry_run: bool = False, +) -> int: + try: + return _add_component( + lang, dir_name, spec_dir=(spec_dir or Path.cwd()).resolve(), project_file=project_file, + namespace=namespace, group_prefix=group_prefix, generate=generate, dry_run=dry_run, + ) + except ComponentError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + +def _add_component( + lang: str, dir_name: str, *, spec_dir: Path, project_file: Path | None, namespace: str | None, + group_prefix: str, generate: bool, dry_run: bool, +) -> int: + if lang not in LANGUAGES: + raise ComponentError(f"unsupported language {lang!r}; choose one of {', '.join(LANGUAGES)}") + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", dir_name): + raise ComponentError(f"{dir_name!r} is not a plain directory name (it is created beside the spec repo)") + + config_path = _find_project_file(spec_dir, project_file) + config = json.loads(config_path.read_text(encoding="utf-8")) + target = spec_dir.parent / dir_name + if target.exists() and any(target.iterdir()): + raise ComponentError(f"{target} already exists and is not empty; seeding never overwrites") + + values = _values(lang, config, spec_dir, dir_name, namespace) + groups = _groups(lang, config, dir_name, values, group_prefix) + taken = {g.get("name", "").lower() for g in config.get("groups", [])} + clash = [g["name"] for g in groups if g["name"].lower() in taken] + if clash: + raise ComponentError( + f"the project file already has group(s) {', '.join(clash)}; pass --group-prefix to name these apart" + ) + values["Groups"] = " ".join(f'"{g["name"]}"' if " " in g["name"] else g["name"] for g in groups) + + print(f"Seeding a {lang} component at {target}") + for g in groups: + print(f" + group {g['name']} [{g['language']}] {g['basePackage']} -> {g['destination']}") + if values["ProjectGeneratorVersion"] and values["ProjectGeneratorVersion"] != values["GeneratorVersion"]: + print( + f"Warning: the project pins generator {values['ProjectGeneratorVersion']}; the seeded " + f"package versions were verified with {values['GeneratorVersion']}. A failing first " + "build names what to adjust.", + file=sys.stderr, + ) + if dry_run: + print("Dry run: nothing written.") + return 0 + + written = _render_tree(kit_root() / "templates" / "components" / lang, target, values) + if shutil.which("git"): + subprocess.run(["git", "init", "-q", str(target)], check=False) + + config.setdefault("groups", []).extend(groups) + config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"Wrote {len(written)} file(s) and added {len(groups)} group(s) to {config_path.name}.") + + names = [g["name"] for g in groups] + script = spec_dir / "scripts" / "specfuse" / "generate.sh" + if generate and script.is_file(): + print(f"\nGenerating {', '.join(names)}. The new repo's seed is not committed yet, so") + print("generate.sh runs with --allow-dirty and will flag it.") + rc = subprocess.run(["bash", str(script), *names, "--allow-dirty"], cwd=spec_dir).returncode + if rc != 0: + print(f"Generation failed (exit {rc}). The seed and the groups stay; fix and rerun generate.sh.", + file=sys.stderr) + return rc + else: + print("\nGenerate the new groups from the spec repo:") + print(" ./scripts/specfuse/bundle-spec.sh && ./scripts/specfuse/bundle-async-spec.sh") + for n in names: + print(f' specfuse authoring generate generate --group "{n}" {config_path.name}') + + build = "dotnet build" if lang == "csharp" else "uv sync --all-packages && uv run pytest" + print(f"\nNext, in {target}:") + print(f" {build}") + print(" git add -A && git commit -m 'Seed component and first regen'") + print(f"Then commit the {config_path.name} change in the spec repo.") + return 0 diff --git a/templates/components/csharp/CLAUDE.md b/templates/components/csharp/CLAUDE.md new file mode 100644 index 0000000..b266491 --- /dev/null +++ b/templates/components/csharp/CLAUDE.md @@ -0,0 +1,18 @@ +# {{Dir}}: instructions for coding agents + +## Generated code: never edit + +- `*/gen-src/**` and every `*.g.cs` file are generated from `../{{SpecRepo}}` by the Specfuse generator. The next regen overwrites them. +- If a task seems to need a change under `gen-src/`, stop. The fix belongs in the API spec (in `../{{SpecRepo}}`) or in the generator. Say so, and note that a regen is required. +- Before writing a model, DTO, repository, mapper or controller by hand, check `gen-src/`. It probably exists already. + +## Where hand-written code goes + +- `src/` in each project. Extend generated types with `partial` classes, or derive from the generated `*Base` classes. +- `{{Prefix}}.Api/src/Program.cs` wires the generated registrations. `Utils/UserHelper.cs` maps claims to the caller the generated controllers expect, and `Filters/ExceptionFilter.cs` maps domain exceptions to HTTP. + +## Layering + +- `{{Prefix}}.Domain` depends on nothing in this solution. +- `{{Prefix}}.Infrastructure` depends on Domain only. +- `{{Prefix}}.Api` depends on Domain. It references Infrastructure only to wire it up in `Program.cs`. diff --git a/templates/components/csharp/Directory.Build.props b/templates/components/csharp/Directory.Build.props new file mode 100644 index 0000000..6c9729a --- /dev/null +++ b/templates/components/csharp/Directory.Build.props @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + false + + $(NoWarn);CS1591;CS8618 + + + + + + + diff --git a/templates/components/csharp/README.md b/templates/components/csharp/README.md new file mode 100644 index 0000000..ffde877 --- /dev/null +++ b/templates/components/csharp/README.md @@ -0,0 +1,36 @@ +# {{Dir}} + +C# backend for **{{ProjectName}}**. Most of the code is generated from the API specifications in [`{{SpecRepo}}`](../{{SpecRepo}}). That repo must be checked out next to this one. + +## Layout + +| Project | Generated (`gen-src/`) | Hand-written (`src/`) | +|---|---|---| +| `{{Prefix}}.Domain` | entities, value objects, enums, domain services, exceptions, events and snapshots | `partial` classes that extend the generated types | +| `{{Prefix}}.Infrastructure` | EF Core `DbContext`, entity configurations, repositories, event runtime | your own infrastructure (external services, custom queries) | +| `{{Prefix}}.Api` | controllers, API models, mappers, authorization | `Program.cs`, `Utils/UserHelper.cs`, `Filters/ExceptionFilter.cs`, middleware | + +`Directory.Build.props` compiles both roots of every project. `gen-src/` is rewritten by every regen: **never edit it**. Extend generated types with `partial` classes or by deriving from the generated `*Base` classes, in `src/`. + +## Run + +```bash +dotnet build +dotnet run --project {{Prefix}}.Api +``` + +Without `ConnectionStrings__Default`, the API runs on an in-memory database. + +## Regenerate + +From `../{{SpecRepo}}`, on a branch of this repo with no uncommitted changes: + +```bash +./scripts/specfuse/generate.sh {{Groups}} +``` + +Review and merge the regen here first. Then advance the spec repo's baseline with `./scripts/specfuse/advance-state.sh`. + +## Where this came from + +`specfuse authoring add-component` seeded the solution, project files, `Directory.Build.props`, this README and the `src/` files once, for generator {{GeneratorVersion}}. They're yours to change, and the kit never rewrites them. When a generator upgrade needs a new package, its release notes and a failing build will say which one. diff --git a/templates/components/csharp/__Prefix__.Api/__Prefix__.Api.csproj b/templates/components/csharp/__Prefix__.Api/__Prefix__.Api.csproj new file mode 100644 index 0000000..457ee05 --- /dev/null +++ b/templates/components/csharp/__Prefix__.Api/__Prefix__.Api.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/templates/components/csharp/__Prefix__.Api/src/Filters/ExceptionFilter.cs b/templates/components/csharp/__Prefix__.Api/src/Filters/ExceptionFilter.cs new file mode 100644 index 0000000..130ba01 --- /dev/null +++ b/templates/components/csharp/__Prefix__.Api/src/Filters/ExceptionFilter.cs @@ -0,0 +1,39 @@ +// Seeded once by `specfuse authoring add-component`. Yours to change. +using {{Prefix}}.Domain.Exceptions; +using {{Prefix}}.Domain.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace {{Prefix}}.Api.Filters; + +/// +/// Turns the generated domain exceptions into HTTP responses carrying their +/// list. Anything else becomes a 500 that leaks no detail. +/// Extend it for exceptions of your own. +/// +public sealed class ExceptionFilter(ILogger logger) : IExceptionFilter +{ + public void OnException(ExceptionContext context) + { + (int status, List? errors) = context.Exception switch + { + BadRequestException e => (BadRequestException.StatusCode, e.Errors), + UnauthorizedException e => (UnauthorizedException.StatusCode, e.Errors), + ForbiddenException e => (ForbiddenException.StatusCode, e.Errors), + NotFoundException e => (NotFoundException.StatusCode, e.Errors), + ConflictException e => (ConflictException.StatusCode, e.Errors), + PreconditionFailedException e => (PreconditionFailedException.StatusCode, e.Errors), + UnprocessableEntityException e => (UnprocessableEntityException.StatusCode, e.Errors), + _ => (InternalException.StatusCode, null), + }; + + if (errors is null) + logger.LogError(context.Exception, "Unhandled exception"); + + context.Result = new ObjectResult(errors ?? new InternalException(InternalException.ErrorMessage).Errors) + { + StatusCode = status, + }; + context.ExceptionHandled = true; + } +} diff --git a/templates/components/csharp/__Prefix__.Api/src/Program.cs b/templates/components/csharp/__Prefix__.Api/src/Program.cs new file mode 100644 index 0000000..888dbf2 --- /dev/null +++ b/templates/components/csharp/__Prefix__.Api/src/Program.cs @@ -0,0 +1,34 @@ +// Seeded once by `specfuse authoring add-component`. Yours to change. +using {{Prefix}}.Api.Authorization; +using {{Prefix}}.Api.Filters; +using {{Prefix}}.Domain; +using {{Prefix}}.Infrastructure; +using {{Prefix}}.Infrastructure.Concurrency; +using {{Prefix}}.Infrastructure.Events; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(options => options.Filters.Add()); + +// Generated registrations (gen-src/**/ConfigureServices.g.cs and friends). +builder.Services.AddServices(); +builder.Services.AddRepositories(); +builder.Services.AddSpecfuseAuthorization(); +builder.Services.AddConcurrencyTokenSink(); + +// Events are buffered per request and dispatched on commit. AddNullEventDispatcher +// drops them, which suits local runs without a broker. In production, call +// AddEventRuntime() (Azure Service Bus topics) or register a dispatcher of your own. +builder.Services.AddEventRuntimeCore().AddNullEventDispatcher(); + +// ConnectionStrings:Default selects SQL Server; without it the app runs on an +// in-memory database, so it starts with no infrastructure at all. +var connectionString = builder.Configuration.GetConnectionString("Default"); +if (string.IsNullOrEmpty(connectionString)) + builder.Services.AddInMemoryDatabase("{{Prefix}}"); +else + builder.Services.AddSqlServerDatabase(connectionString); + +var app = builder.Build(); +app.MapControllers(); +app.Run(); diff --git a/templates/components/csharp/__Prefix__.Api/src/Utils/UserHelper.cs b/templates/components/csharp/__Prefix__.Api/src/Utils/UserHelper.cs new file mode 100644 index 0000000..7d8f51f --- /dev/null +++ b/templates/components/csharp/__Prefix__.Api/src/Utils/UserHelper.cs @@ -0,0 +1,16 @@ +// Seeded once by `specfuse authoring add-component`. Yours to change. +// +// Generated controllers call UserHelper.GetUser(User) to build the CallerContext +// they pass to the domain. The generator only references it by name, so this +// class is hand-written: map your identity provider's claims here (user id, +// roles, tenant memberships) once authentication is wired in. +using System.Security.Claims; +using {{Prefix}}.Domain.Users; + +namespace {{Prefix}}.Api.Utils; + +public static class UserHelper +{ + public static AuthenticatedUser GetUser(ClaimsPrincipal user) => + new(user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user.Identity?.Name ?? "anonymous"); +} diff --git a/templates/components/csharp/__Prefix__.Domain/__Prefix__.Domain.csproj b/templates/components/csharp/__Prefix__.Domain/__Prefix__.Domain.csproj new file mode 100644 index 0000000..636b0ff --- /dev/null +++ b/templates/components/csharp/__Prefix__.Domain/__Prefix__.Domain.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/templates/components/csharp/__Prefix__.Infrastructure/__Prefix__.Infrastructure.csproj b/templates/components/csharp/__Prefix__.Infrastructure/__Prefix__.Infrastructure.csproj new file mode 100644 index 0000000..cac69db --- /dev/null +++ b/templates/components/csharp/__Prefix__.Infrastructure/__Prefix__.Infrastructure.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/templates/components/csharp/__Prefix__.slnx b/templates/components/csharp/__Prefix__.slnx new file mode 100644 index 0000000..e6f19c9 --- /dev/null +++ b/templates/components/csharp/__Prefix__.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/templates/components/csharp/gitignore b/templates/components/csharp/gitignore new file mode 100644 index 0000000..089a99d --- /dev/null +++ b/templates/components/csharp/gitignore @@ -0,0 +1,9 @@ +bin/ +obj/ +*.user +.vs/ +.idea/ +TestResults/ + +# *.generated-paths.json is deliberately NOT ignored: the generator's path +# manifest is committed, and is byte-identical when a regen moved nothing. diff --git a/templates/components/python/CLAUDE.md b/templates/components/python/CLAUDE.md new file mode 100644 index 0000000..9908d9e --- /dev/null +++ b/templates/components/python/CLAUDE.md @@ -0,0 +1,12 @@ +# {{Dir}}: instructions for coding agents + +## Generated code: never edit + +- Every `_generated/` folder under `libs/` is generated from `../{{SpecRepo}}` by the Specfuse generator. The next regen overwrites it. +- If a task seems to need a change in generated code, stop. The fix belongs in the API spec (in `../{{SpecRepo}}`) or in the generator. Say so, and note that a regen is required. +- Before writing a model, enum, event or repository by hand, check `{{ContractsPackage}}` and `{{CorePackage}}`. It probably exists already. + +## Where hand-written code goes + +- Beside `_generated/`, never inside it. Subclass or wrap the generated classes. +- New services and workers go in their own workspace members, depending on `{{ContractsDist}}` / `{{CoreDist}}`. diff --git a/templates/components/python/README.md b/templates/components/python/README.md new file mode 100644 index 0000000..35a512d --- /dev/null +++ b/templates/components/python/README.md @@ -0,0 +1,33 @@ +# {{Dir}} + +Python services for **{{ProjectName}}**. The libraries under `libs/` are generated from the API specifications in [`{{SpecRepo}}`](../{{SpecRepo}}). That repo must be checked out next to this one. + +## Layout + +| Library | Package | Generated (`_generated/`) | +|---|---|---| +| `libs/contracts` | `{{ContractsPackage}}` | SQLAlchemy models, enums, value objects, events, event builders, snapshots | +| `libs/core` | `{{CorePackage}}` | repositories, unit of work, session, event outbox and emitters | + +Every `_generated/` folder is rewritten by every regen: **never edit it**. Put hand-written code beside it: subclass or wrap the generated classes in your own modules. Services and workers that use these libraries go in new workspace members (add them to `[tool.uv.workspace]`). + +## Set up and test + +```bash +uv sync --all-packages +uv run pytest +``` + +## Regenerate + +From `../{{SpecRepo}}`, on a branch of this repo with no uncommitted changes: + +```bash +./scripts/specfuse/generate.sh {{Groups}} +``` + +Review and merge the regen here first. Then advance the spec repo's baseline with `./scripts/specfuse/advance-state.sh`. + +## Where this came from + +`specfuse authoring add-component` seeded the workspace, the `pyproject.toml` files and this README once, for generator {{GeneratorVersion}}. They're yours to change, and the kit never rewrites them. diff --git a/templates/components/python/gitignore b/templates/components/python/gitignore new file mode 100644 index 0000000..5f4bc10 --- /dev/null +++ b/templates/components/python/gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.egg-info/ +.pytest_cache/ +.coverage + +# *.generated-paths.json is deliberately NOT ignored: the generator's path +# manifest is committed, and is byte-identical when a regen moved nothing. diff --git a/templates/components/python/libs/contracts/pyproject.toml b/templates/components/python/libs/contracts/pyproject.toml new file mode 100644 index 0000000..bbdc3f7 --- /dev/null +++ b/templates/components/python/libs/contracts/pyproject.toml @@ -0,0 +1,17 @@ +# Seeded once by `specfuse authoring add-component` for generator {{GeneratorVersion}}. Yours to change. +[project] +name = "{{ContractsDist}}" +version = "0.1.0" +description = "Generated contracts for {{ProjectName}}: SQLAlchemy models, enums, value objects, events and snapshots." +requires-python = ">=3.12" +dependencies = [ + "pydantic>=2.0", + "sqlalchemy>=2.0", +] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/templates/components/python/libs/core/pyproject.toml b/templates/components/python/libs/core/pyproject.toml new file mode 100644 index 0000000..98f8166 --- /dev/null +++ b/templates/components/python/libs/core/pyproject.toml @@ -0,0 +1,21 @@ +# Seeded once by `specfuse authoring add-component` for generator {{GeneratorVersion}}. Yours to change. +[project] +name = "{{CoreDist}}" +version = "0.1.0" +description = "Generated runtime for {{ProjectName}}: repositories, unit of work, event outbox and emitters." +requires-python = ">=3.12" +dependencies = [ + "{{ContractsDist}}", + "opentelemetry-api>=1.0", + "pydantic>=2.0", + "pydantic-settings>=2.0", + "sqlalchemy[asyncio]>=2.0", + "structlog>=24.1", +] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/templates/components/python/pyproject.toml b/templates/components/python/pyproject.toml new file mode 100644 index 0000000..93f88b5 --- /dev/null +++ b/templates/components/python/pyproject.toml @@ -0,0 +1,25 @@ +# Seeded once by `specfuse authoring add-component` for generator {{GeneratorVersion}}. Yours to change. +[project] +name = "{{Dir}}" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [] + +[tool.uv.workspace] +members = ["libs/*"] + +[tool.uv.sources] +{{ContractsDist}} = { workspace = true } +{{CoreDist}} = { workspace = true } + +[dependency-groups] +dev = ["pytest>=8"] + +[tool.pytest.ini_options] +testpaths = ["libs"] + +[tool.ruff] +extend-exclude = ["*/_generated/**"] + +[tool.coverage.run] +omit = ["*/_generated/*"]