From 8b664f3469906168966ce12fc11eb589bc3e0db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Labont=C3=A9?= Date: Thu, 29 Oct 2026 10:44:48 -0400 Subject: [PATCH] fix(scripts): generate-scenario-docs.sh reads its Markdown groups from the project file The script ran two hardcoded group names and cleaned and counted a hardcoded docs/generated, so a project whose Markdown groups had other names or destinations got a generator error or a summary of the wrong folder. It now: - runs every group with "language": "markdown" in -project.json; - cleans and counts each group's destination, but never removes one outside the repo, the repo root, or a folder holding api/docs/flows or api/specs; - exits non-zero with a clear message when there is no Markdown group; - no longer dies on its summary when api/docs/flows does not exist. The project-file reader moves out of generate.sh into project-file.sh, which both scripts source, so there is one reader. api/docs/flows stays fixed until the layout work in #184. Closes #191 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 63 +++++++++ .../skills/generate/SKILL.md | 4 +- .../project-init/scripts/specfuse/README.md | 5 +- .../specfuse/generate-scenario-docs.sh | 121 +++++++++++++----- .../project-init/scripts/specfuse/generate.sh | 41 +----- .../scripts/specfuse/project-file.sh | 48 +++++++ 6 files changed, 209 insertions(+), 73 deletions(-) create mode 100644 templates/project-init/scripts/specfuse/project-file.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fb0fe0..b769b07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/plugins/specfuse-authoring/skills/generate/SKILL.md b/plugins/specfuse-authoring/skills/generate/SKILL.md index 713da45..6f0a472 100644 --- a/plugins/specfuse-authoring/skills/generate/SKILL.md +++ b/plugins/specfuse-authoring/skills/generate/SKILL.md @@ -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. diff --git a/templates/project-init/scripts/specfuse/README.md b/templates/project-init/scripts/specfuse/README.md index a8584e8..7523988 100644 --- a/templates/project-init/scripts/specfuse/README.md +++ b/templates/project-init/scripts/specfuse/README.md @@ -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 diff --git a/templates/project-init/scripts/specfuse/generate-scenario-docs.sh b/templates/project-init/scripts/specfuse/generate-scenario-docs.sh index 3ea83c2..9495005 100755 --- a/templates/project-init/scripts/specfuse/generate-scenario-docs.sh +++ b/templates/project-init/scripts/specfuse/generate-scenario-docs.sh @@ -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 (-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 @@ -66,22 +68,72 @@ while [[ $# -gt 0 ]]; do esac done +if [[ -z "${CONFIG_FILE}" || ! -f "${CONFIG_FILE}" ]]; then + echo "Project file not found: expected -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 -project.json in ${ROOT_DIR}" >&2 - exit 1 -fi +# abs_dest — 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..." @@ -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=() @@ -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 diff --git a/templates/project-init/scripts/specfuse/generate.sh b/templates/project-init/scripts/specfuse/generate.sh index a3244ed..66dfe6c 100755 --- a/templates/project-init/scripts/specfuse/generate.sh +++ b/templates/project-init/scripts/specfuse/generate.sh @@ -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 -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: namelanguagedestinationbasePackage. -# 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=() diff --git a/templates/project-init/scripts/specfuse/project-file.sh b/templates/project-init/scripts/specfuse/project-file.sh new file mode 100644 index 0000000..70419ae --- /dev/null +++ b/templates/project-init/scripts/specfuse/project-file.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Project-file reader, sourced by generate.sh and generate-scenario-docs.sh so +# the project file (-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 — the first -project.json in , 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: namelanguagedestinationbasePackage. +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 — 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 +}