Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions .github/workflows/example-regen.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
69 changes: 69 additions & 0 deletions docs/add-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<!--
Copyright 2026 Specfuse Contributors
Licensed under the Apache License, Version 2.0. See LICENSE.
-->

# 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/
├── <Ns>.slnx
├── Directory.Build.props compiles src/ and gen-src/ in every project
├── <Ns>.Domain/<Ns>.Domain.csproj
├── <Ns>.Infrastructure/…csproj references Domain
├── <Ns>.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 <Ns>.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 <abbr>_contracts: models, enums, value objects, events, snapshots
├── libs/core/pyproject.toml <abbr>_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.
18 changes: 18 additions & 0 deletions specfuse/authoring/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
Expand All @@ -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)
Expand Down
Loading
Loading