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
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,69 @@ jobs:
grep -q "nothing to advance" <<<"$out" || fail "unexpected message: $out"
echo "==> generate.sh / advance-state.sh guards behave."

- name: generate-scenario-docs.sh runs the project file's Markdown groups (#191)
run: |
set -uo pipefail
P=/tmp/smoke
fail() { echo "::error::$1" >&2; exit 1; }

# A copy of the scaffold with bundling stubbed and a fake CLI that records
# each generator call, so no jar, Java or redocly is needed.
S=$(mktemp -d "${TMPDIR:-/tmp}/scenario-docs.XXXXXX") || fail "mktemp failed"
cp -R "$P" "$S/specs"
for b in bundle-spec.sh bundle-async-spec.sh; do
printf '#!/bin/sh\nexit 0\n' > "$S/specs/scripts/specfuse/$b"
done
cat > "$S/fake-cli" <<'FAKE'
#!/bin/sh
echo "$*" >> "$CALLS"
FAKE
chmod +x "$S/fake-cli"
export SPECFUSE_AUTHORING="$S/fake-cli" CALLS="$S/calls"
set_groups() {
python3 - "$S/specs" "$1" <<'PY'
import json, pathlib, sys
p = next(pathlib.Path(sys.argv[1]).glob("*-project.json"))
c = json.loads(p.read_text())
c["groups"] = json.loads(sys.argv[2])
p.write_text(json.dumps(c, indent=2))
PY
}
run() { : > "$CALLS"; (cd "$S/specs" && scripts/specfuse/generate-scenario-docs.sh --skip-index "$@" 2>&1); }

# The scaffold's project file declares no groups: say so, exit non-zero, never call the generator.
out=$(run) && fail "ran with no Markdown group: $out"
grep -q "No Markdown group" <<<"$out" || fail "unexpected message: $out"
[ ! -s "$CALLS" ] || fail "the generator was called with no Markdown group"

# The two groups the script used to hardcode: same calls, same summary sections.
set_groups '[{"name": "Domain", "language": "C#", "destination": "../Backend/", "artifacts": ["entity"]},
{"name": "Documentation - Scenarios", "language": "markdown", "destination": "./api/docs", "artifacts": ["scenarioDocument"]},
{"name": "Documentation - Technical References", "language": "markdown", "destination": "./docs/generated", "artifacts": ["docsIndex"]}]'
mkdir -p "$S/specs/docs/generated" && echo x > "$S/specs/docs/generated/old.md"
echo keep > "$S/specs/api/docs/hand-written.md"
out=$(run --clean) || fail "two-group run failed: $out"
[ "$(wc -l < "$CALLS" | tr -d ' ')" = 2 ] || fail "expected 2 generator calls: $(cat "$CALLS")"
grep -q -- '--group Documentation - Scenarios ' "$CALLS" || fail "Scenarios group not run: $(cat "$CALLS")"
grep -q -- '--group Documentation - Technical References ' "$CALLS" || fail "Technical References group not run"
grep -q -- '--group Domain' "$CALLS" && fail "a non-Markdown group was run"
[ ! -e "$S/specs/docs/generated" ] || fail "--clean left docs/generated"
[ -f "$S/specs/api/docs/hand-written.md" ] || fail "--clean removed a destination holding the scenario specs"
grep -q "Total: 0 scenario doc(s) generated." <<<"$out" || fail "scenario summary missing: $out"
grep -q "Generated Documentation - Scenarios docs" <<<"$out" && fail "the scenario destination was listed as a whole: $out"

# The handbook's single Documentation group: that group, that folder.
set_groups '[{"name": "Documentation", "language": "markdown", "destination": "./docs/reference", "artifacts": ["docsIndex"]}]'
mkdir -p "$S/specs/docs/reference" && echo x > "$S/specs/docs/reference/a.md"
out=$(run) || fail "single-group run failed: $out"
[ "$(cat "$CALLS")" = "generate generate --progress --group Documentation $(cd "$S/specs" && ls "$PWD"/*-project.json)" ] \
|| fail "unexpected generator call: $(cat "$CALLS")"
grep -q "Generated Documentation docs (./docs/reference)" <<<"$out" || fail "destination not summarised: $out"
grep -q "Total: 1 Documentation doc(s) generated." <<<"$out" || fail "destination not counted: $out"
out=$(run --clean) || fail "single-group clean failed: $out"
[ ! -e "$S/specs/docs/reference/a.md" ] || fail "--clean ignored the project file's destination"
echo "==> generate-scenario-docs.sh runs the project file's Markdown groups."

- name: Scaffolded project validates with its own scripts
run: |
set -euo pipefail
Expand Down
4 changes: 1 addition & 3 deletions plugins/specfuse-authoring/skills/generate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,6 @@ Artifact groups are declared in the project config's `groups` array; generate th
```
The word `generate` appears twice on purpose: `specfuse authoring generate` launches the pinned generator and passes everything after it through verbatim, so the generator's own `generate` command must follow. Without it the generator prints `Unknown option: --group` and its usage text, and generates nothing.
5. **Run language post-steps** for groups that need them. Dart: the generator writes `part '*.g.dart'` / `part '*.freezed.dart'` directives but does not run `source_gen`, so run `dart pub get && dart run build_runner build --delete-conflicting-outputs` in the generated package before analysing it.
6. **Generate documentation (Markdown)** — if a Markdown group is selected, run `./scripts/specfuse/generate-scenario-docs.sh` instead of step 4 for it. It bundles the specs, then generates the two Markdown groups the scaffold defines, then adds cross-links and the index:
- `Documentation - Scenarios` → `scenarioDocument`, `scenarioIndex` (output: `./api/docs`)
- `Documentation - Technical References` → `recipeDocumentation`, `entityDiagram`, `eventCatalog`, `channelTopology`, `docsIndex` (output: `./docs/generated`)
6. **Generate documentation (Markdown)** — if a Markdown group is selected, run `./scripts/specfuse/generate-scenario-docs.sh` instead of step 4 for it. It bundles the specs, generates every group of the project file with `"language": "markdown"` (whatever their names), then adds cross-links and the scenario index. With `--clean` it first removes each Markdown group's `destination`, except one outside the project, the project root, or a folder holding `api/docs/flows/` or `api/specs/`; scenario pages under `api/docs/flows/` are deleted file by file instead. It stops with an error when the project file has no Markdown group.

Report the outcome of each group: generator exit code `0` is success, `3` is a generation failure with a structured error, `1`/`2` are validation or system errors. If generation fails, analyze the error output and suggest fixes. For each group written into another repo, name the repo and remind the user that the regen is reviewed and merged there before `.specfuse/state` baselines are advanced.
5 changes: 3 additions & 2 deletions templates/project-init/scripts/specfuse/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,11 @@ yet exits 0.
| `dedupe-async-bundle.py` | Dedup helper — called by `bundle-async-spec.sh`. |
| `serve-docs.sh` | Redocly preview server for the OpenAPI docs (default port 8081). |
| `serve-async-docs.sh` | Preview server for the AsyncAPI docs. |
| `generate-scenario-docs.sh` | Bundle, then generate scenario + technical-reference docs. |
| `generate-scenario-docs.sh` | Bundle, then generate every Markdown group in the project file, plus scenario cross-links and the index. |
| `generate.sh` | Bundle once, then generate the project file's groups one at a time: by name, `--lang`, or `all`; `--list`, `--dry-run`. Refuses destination repos with uncommitted changes. |
| `advance-state.sh` | After a regen PR merges in a destination repo, advance that group's `stateDir` baseline from the merged code. |
| `generate-scenario-index.sh` | Build the scenario index — called by the above. |
| `generate-scenario-index.sh` | Build the scenario index — called by `generate-scenario-docs.sh`. |
| `project-file.sh` | Project-file reader sourced by `generate.sh` and `generate-scenario-docs.sh`. Not run directly. |
| `build-prompt-index.sh` | Reverse index of `api/docs/implementation-prompts/`, for `/prepare-handoff`. |

`generate-scenario-docs.sh` drives the generator through
Expand Down
121 changes: 90 additions & 31 deletions templates/project-init/scripts/specfuse/generate-scenario-docs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,17 @@ fi
# jar with no subcommand.
RUN_GENERATOR=("${SPECFUSE_CLI[@]}" generate)

# The groups to run and where they write come from the project file: every
# group with `"language": "markdown"`, read by the same helper generate.sh uses.
# shellcheck source=project-file.sh
. "${SCRIPT_DIR}/project-file.sh"

# The project file is named after the project (<name>-project.json), so find it
# rather than hardcoding one project's name.
CONFIG_FILE=""
for candidate in "${ROOT_DIR}"/*-project.json; do
if [[ -f "$candidate" ]]; then
CONFIG_FILE="$candidate"
break
fi
done
CONFIG_FILE="$(find_project_file "${ROOT_DIR}")"

# Scenario specs live here, so it is cleaned file by file, never removed.
FLOWS_DIR="${ROOT_DIR}/api/docs/flows"

CLEAN=false
SKIP_INDEX=false
Expand Down Expand Up @@ -66,22 +68,72 @@ while [[ $# -gt 0 ]]; do
esac
done

if [[ -z "${CONFIG_FILE}" || ! -f "${CONFIG_FILE}" ]]; then
echo "Project file not found: expected <name>-project.json in ${ROOT_DIR}" >&2
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "python3 not found (needed to read the project file)." >&2
exit 1
fi
CONFIG="${CONFIG_FILE}"

MD_GROUPS=()
MD_DESTS=()
while IFS=$'\t' read -r name lang dest _; do
if [[ "$(canon_lang "$lang")" == "markdown" ]]; then
MD_GROUPS+=("$name")
MD_DESTS+=("${dest:-.}")
fi
done < <(groups_tsv)

if [[ ${#MD_GROUPS[@]} -eq 0 ]]; then
echo "No Markdown group in $(basename "${CONFIG_FILE}"): nothing to generate." >&2
echo "Add a group with \"language\": \"markdown\" (see Project_File.md, groups[])." >&2
exit 1
fi

if ! command -v "${SPECFUSE_CLI[0]}" >/dev/null 2>&1; then
echo "${SPECFUSE_CLI[*]} not found on PATH." >&2
echo "Install the suite: pipx install specfuse (or: uv tool install specfuse)" >&2
exit 1
fi

if [[ -z "${CONFIG_FILE}" || ! -f "${CONFIG_FILE}" ]]; then
echo "Project file not found: expected <name>-project.json in ${ROOT_DIR}" >&2
exit 1
fi
# abs_dest <destination> — the destination as an absolute path, resolved from
# the project root the way the generator resolves it.
abs_dest() {
python3 -c 'import os, sys; print(os.path.normpath(os.path.join(sys.argv[1], sys.argv[2])))' "${ROOT_DIR}" "$1"
}

# Whether a destination may be removed whole: inside this repo, not its root,
# and not holding the scenario specs or the specs themselves.
removable_dest() {
case "$1/" in
"${ROOT_DIR}"/?*) ;;
*) return 1 ;;
esac
case "${FLOWS_DIR}/" in "$1"/*) return 1 ;; esac
case "${ROOT_DIR}/api/specs/" in "$1"/*) return 1 ;; esac
return 0
}

echo "Markdown groups in $(basename "${CONFIG_FILE}"):"
for i in "${!MD_GROUPS[@]}"; do
echo " - ${MD_GROUPS[$i]} -> ${MD_DESTS[$i]}"
done

if [[ "${CLEAN}" == true ]]; then
echo "Cleaning generated scenario docs..."
find "${ROOT_DIR}/api/docs/flows" -path "*/scenarios/*.md" -delete 2>/dev/null || true
echo "Cleaning generated technical reference docs..."
rm -rf "${ROOT_DIR}/docs/generated" 2>/dev/null || true
find "${FLOWS_DIR}" -path "*/scenarios/*.md" -delete 2>/dev/null || true
for i in "${!MD_GROUPS[@]}"; do
dest="$(abs_dest "${MD_DESTS[$i]}")"
if removable_dest "$dest"; then
echo "Cleaning ${MD_DESTS[$i]} (${MD_GROUPS[$i]})..."
rm -rf "$dest" 2>/dev/null || true
else
echo "Not cleaning ${MD_DESTS[$i]} (${MD_GROUPS[$i]}): outside this repo, its root, or holds the specs."
fi
done
fi

echo "Bundling OpenAPI specs..."
Expand All @@ -90,12 +142,11 @@ echo "Bundling OpenAPI specs..."
echo "Bundling AsyncAPI specs..."
"${SCRIPT_DIR}/bundle-async-spec.sh" "${ROOT_DIR}/api/specs/v1/asyncapi.yaml" "${ROOT_DIR}/output/asyncapi-bundled.yaml"

echo "Generating scenario documentation..."
cd "${ROOT_DIR}"
"${RUN_GENERATOR[@]}" generate --progress --group "Documentation - Scenarios" ${ARGS[@]+"${ARGS[@]}"} "${CONFIG_FILE}"

echo "Generating technical reference documentation..."
"${RUN_GENERATOR[@]}" generate --progress --group "Documentation - Technical References" ${ARGS[@]+"${ARGS[@]}"} "${CONFIG_FILE}"
for g in "${MD_GROUPS[@]}"; do
echo "Generating ${g}..."
"${RUN_GENERATOR[@]}" generate --progress --group "$g" ${ARGS[@]+"${ARGS[@]}"} "${CONFIG_FILE}"
done

# Post-generation: cross-links + index
INDEX_ARGS=()
Expand All @@ -111,20 +162,28 @@ fi
# Summary
echo ""
echo "Generated scenario docs:"
find "${ROOT_DIR}/api/docs/flows" -path "*/scenarios/*.md" -not -name "index.md" | sort | while read -r f; do
echo " ${f#${ROOT_DIR}/}"
done
COUNT=$(find "${ROOT_DIR}/api/docs/flows" -path "*/scenarios/*.md" -not -name "index.md" 2>/dev/null | wc -l | tr -d ' ')
COUNT=0
if [[ -d "${FLOWS_DIR}" ]]; then
find "${FLOWS_DIR}" -path "*/scenarios/*.md" -not -name "index.md" | sort | while read -r f; do
echo " ${f#"${ROOT_DIR}"/}"
done
COUNT=$(find "${FLOWS_DIR}" -path "*/scenarios/*.md" -not -name "index.md" | wc -l | tr -d ' ')
fi
echo ""
echo "Total: ${COUNT} scenario doc(s) generated."

echo ""
echo "Generated technical reference docs:"
if [[ -d "${ROOT_DIR}/docs/generated" ]]; then
find "${ROOT_DIR}/docs/generated" -name "*.md" | sort | while read -r f; do
echo " ${f#${ROOT_DIR}/}"
# A destination holding the scenario folder is covered by the count above;
# listing every Markdown file under it would also list hand-written docs.
for i in "${!MD_GROUPS[@]}"; do
dest="$(abs_dest "${MD_DESTS[$i]}")"
case "${FLOWS_DIR}/" in "$dest"/*) continue ;; esac
[[ -d "$dest" ]] || continue
echo ""
echo "Generated ${MD_GROUPS[$i]} docs (${MD_DESTS[$i]}):"
find "$dest" -name "*.md" | sort | while read -r f; do
echo " ${f#"${ROOT_DIR}"/}"
done
REF_COUNT=$(find "${ROOT_DIR}/docs/generated" -name "*.md" 2>/dev/null | wc -l | tr -d ' ')
REF_COUNT=$(find "$dest" -name "*.md" 2>/dev/null | wc -l | tr -d ' ')
echo ""
echo "Total: ${REF_COUNT} technical reference doc(s) generated."
fi
echo "Total: ${REF_COUNT} ${MD_GROUPS[$i]} doc(s) generated."
done
41 changes: 4 additions & 37 deletions templates/project-init/scripts/specfuse/generate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,46 +54,13 @@ RUN_GENERATOR=("${SPECFUSE_CLI[@]}" generate)

die() { echo "❌ $1" >&2; exit 1; }

CONFIG=""
for candidate in "$ROOT_DIR"/*-project.json; do
if [ -f "$candidate" ]; then
CONFIG="$candidate"
break
fi
done
# shellcheck source=project-file.sh
. "$SCRIPT_DIR/project-file.sh"

CONFIG="$(find_project_file "$ROOT_DIR")"
[ -n "$CONFIG" ] || die "Project file not found: expected <name>-project.json in $ROOT_DIR"
command -v python3 &> /dev/null || die "python3 not found (needed to read the project file)."

# groups_tsv — one line per group: name<TAB>language<TAB>destination<TAB>basePackage.
# python3 rather than jq: no extra dependency, and no CRLF output on Windows.
groups_tsv() {
python3 - "$CONFIG" <<'PY'
import json, sys
for g in json.load(open(sys.argv[1], encoding="utf-8")).get("groups", []):
print("\t".join(str(g.get(k) or "") for k in ("name", "language", "destination", "basePackage")))
PY
}
config_value() {
python3 - "$CONFIG" "$1" <<'PY'
import json, sys
print(json.load(open(sys.argv[1], encoding="utf-8")).get(sys.argv[2]) or "")
PY
}

lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }

# Language aliases, matched case-insensitively against each group's `language`.
canon_lang() {
case "$(lower "$1")" in
c#|cs|csharp|dotnet) echo "c#" ;;
py|python) echo "python" ;;
dart|flutter) echo "dart" ;;
ts|typescript) echo "typescript" ;;
md|markdown|docs) echo "markdown" ;;
*) lower "$1" ;;
esac
}

usage() { sed -n '4,15p' "$0" | sed 's/^# \{0,1\}//'; }

TARGETS=()
Expand Down
48 changes: 48 additions & 0 deletions templates/project-init/scripts/specfuse/project-file.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/bin/bash
# Project-file reader, sourced by generate.sh and generate-scenario-docs.sh so
# the project file (<name>-project.json) has one reader. Not run directly.
#
# The functions read the project file named by $CONFIG. They need python3
# rather than jq: no extra dependency, and no CRLF output on Windows.

# find_project_file <root> — the first <name>-project.json in <root>, or nothing.
find_project_file() {
local candidate
for candidate in "$1"/*-project.json; do
if [ -f "$candidate" ]; then
echo "$candidate"
return 0
fi
done
}

# groups_tsv — one line per group: name<TAB>language<TAB>destination<TAB>basePackage.
groups_tsv() {
python3 - "$CONFIG" <<'PY'
import json, sys
for g in json.load(open(sys.argv[1], encoding="utf-8")).get("groups", []):
print("\t".join(str(g.get(k) or "") for k in ("name", "language", "destination", "basePackage")))
PY
}

# config_value <key> — a top-level value of the project file, or nothing.
config_value() {
python3 - "$CONFIG" "$1" <<'PY'
import json, sys
print(json.load(open(sys.argv[1], encoding="utf-8")).get(sys.argv[2]) or "")
PY
}

lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }

# Language aliases, matched case-insensitively against each group's `language`.
canon_lang() {
case "$(lower "$1")" in
c#|cs|csharp|dotnet) echo "c#" ;;
py|python) echo "python" ;;
dart|flutter) echo "dart" ;;
ts|typescript) echo "typescript" ;;
md|markdown|docs) echo "markdown" ;;
*) lower "$1" ;;
esac
}
Loading