diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a12c902..74ca9ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,12 @@ jobs: python -m pip install --upgrade pip build python -m build --wheel --outdir dist + # docs/flows.html ships into every project; a kit change that would make + # it describe skills, states, layers or upgrade rules that no longer + # exist must fail here, not in a user's browser. + - name: Flows page matches the kit + run: python scripts/check-flows-page.py + - name: Clean-install and bootstrap from the built wheel run: | python -m venv /tmp/ci @@ -57,6 +63,7 @@ jobs: test -f /tmp/smoke/.specfuse/authoring/VERSION test -f /tmp/smoke/.specfuse/authoring/.scaffold-manifest test -f /tmp/smoke/.specfuse/authoring/schemas/spectral/specfuse-openapi.yaml + cmp docs/flows.html /tmp/smoke/.specfuse/authoring/docs/flows.html # The overlay's whole job is reaching projects that already exist. Each # case below is one the manifest was added to make possible — without it diff --git a/README.md b/README.md index 5601291..c67c09d 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ See [`examples/hello-orders/`](examples/hello-orders/) for a complete worked exa | Look up a `x-*` extension | [`handbooks/Vendor_Extensions.md`](handbooks/Vendor_Extensions.md) | — | | Implement field encryption at runtime | [`handbooks/Field_Encryption_Runtime.md`](handbooks/Field_Encryption_Runtime.md) | — | | Configure the generator project file | [`handbooks/Project_File.md`](handbooks/Project_File.md) | — | +| See how it all fits together | [`docs/flows.html`](docs/flows.html), an interactive map of the lifecycle, project layout, skills, validation layers and upgrades. Every project gets a copy at `.specfuse/authoring/docs/flows.html`. | Open it in a browser | | See a complete worked example | [`examples/hello-orders/README.md`](examples/hello-orders/README.md) | — | ## Relationship to other Specfuse repos diff --git a/docs/flows.html b/docs/flows.html new file mode 100644 index 0000000..4240f79 --- /dev/null +++ b/docs/flows.html @@ -0,0 +1,2111 @@ + + + + + + +Specfuse Authoring Flows + + + + + + +
+
+
+

Specfuse Authoring Flows

+

How an idea becomes validated specs, what lives where in your project, which skill to reach for, and what specfuse authoring upgrade will do to your files.

+
+
+ a skill or the kit + you decide + generated or downstream + done here +
+
+ +
+ +
+
+ +

The handbooks and templates are the source of truth; this page is a map of them and links to each. It ships with the kit and specfuse authoring upgrade replaces it, so it describes the version you have installed. Skills are invoked as /specfuse-authoring:<name> once the plugin is installed.

+
+ + + + + + diff --git a/docs/getting-started.md b/docs/getting-started.md index 8ea774c..f7ad5b0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -168,6 +168,7 @@ To pull newer skills themselves, run `/plugin update specfuse-authoring@specfuse ## Where to go next +- [`flows.html`](flows.html) — interactive map of the lifecycle, project layout, skills, validation and upgrades; also in your project at `.specfuse/authoring/docs/flows.html` - [`README.md`](../README.md) — kit overview and asset map - [`handbooks/`](../handbooks/) — the full authoring contract - [`component-repos.md`](component-repos.md) — setting up the repos that receive generated code diff --git a/pyproject.toml b/pyproject.toml index eed2fe5..f2d5d86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ packages = ["specfuse"] "templates" = "specfuse/authoring/_kit/templates" "schemas" = "specfuse/authoring/_kit/schemas" "generator.lock" = "specfuse/authoring/_kit/generator.lock" +"docs/flows.html" = "specfuse/authoring/_kit/docs/flows.html" [tool.hatch.build.targets.sdist] include = [ @@ -51,6 +52,7 @@ include = [ "templates", "schemas", "generator.lock", + "docs/flows.html", "README.md", "LICENSE", ] diff --git a/scripts/check-flows-page.py b/scripts/check-flows-page.py new file mode 100644 index 0000000..aa495eb --- /dev/null +++ b/scripts/check-flows-page.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Fail when docs/flows.html drifts from the kit it describes. + +The flows page ships into every project as .specfuse/authoring/docs/flows.html +and keeps its facts in one JSON block (#kit-data). This check compares that +block with the sources it summarises, so a kit change that would make the page +wrong fails CI instead of shipping a stale map: + + - skills and agents against plugins/specfuse-authoring/{skills,agents} + - the skill finder covers every skill and names no unknown one + - backlog states against the State: line of the backlog template, + and the idea state machine against those states + - no-orchestrator table covers every skill + - project file example keys against Project_File.md's quick reference, + artifact ids against its per-language lists, and + every section anchor the page links + - readiness checklist against the idea dossier template + - validation layers against LAYERS in scripts/specfuse/validate-specs.sh + - Spectral rule names against the kit's rulesets + - upgrade overlay against scaffold.OVERLAY, and the simulator's quoted + output against the strings scaffold.py prints + - relative links resolve both in this repo and in an installed project + +Usage: python3 scripts/check-flows-page.py (exit 1 on drift) +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path, PurePosixPath + +ROOT = Path(__file__).resolve().parents[1] +PAGE = ROOT / "docs" / "flows.html" +INSTALLED_PAGE = PurePosixPath(".specfuse/authoring/docs/flows.html") + +sys.path.insert(0, str(ROOT)) +from specfuse.authoring import scaffold # noqa: E402 + +errors: list[str] = [] + + +def check(cond: bool, msg: str) -> None: + if not cond: + errors.append(msg) + + +def same_set(label: str, page: set[str], source: set[str]) -> None: + if missing := source - page: + errors.append(f"{label}: missing from the page: {sorted(missing)}") + if extra := page - source: + errors.append(f"{label}: on the page but not in the kit: {sorted(extra)}") + + +html = PAGE.read_text(encoding="utf-8") +m = re.search(r'', html, re.S) +if not m: + sys.exit("check-flows-page: no #kit-data block in docs/flows.html") +data = json.loads(m.group(1)) + +# Skills and agents. +plugin = ROOT / "plugins" / "specfuse-authoring" +skill_dirs = {p.parent.name for p in (plugin / "skills").glob("*/SKILL.md")} +agent_files = {p.stem for p in (plugin / "agents").glob("*.md")} +same_set("skills", set(data["skills"]), skill_dirs) +same_set("agents", set(data["agents"]), agent_files) + +intent_skills = {s for g in data["intents"] for _, s in g["items"] if s} +same_set("skill finder", intent_skills, skill_dirs) +for lc in data["lifecycles"]: + for st in lc["stages"]: + for s in filter(None, [st.get("skill"), *st.get("also", [])]): + check(s in skill_dirs, f"lifecycle {lc['id']!r} stage {st['t']!r} names unknown skill {s!r}") + check(st["state"] in data["backlogStates"], f"lifecycle stage {st['t']!r} has unknown state {st['state']!r}") +for name, agent in data["agents"].items(): + for s in agent["by"]: + check(s in skill_dirs, f"agent {name!r} is used by unknown skill {s!r}") + +# State machine: the idea machine draws exactly the template's states, every +# transition names a real skill, and the no-orchestrator table covers every skill. +machine = data["machine"] +same_set("idea state machine", set(machine["backlog"]["states"]), set(data["backlogStates"])) +for key in ("backlog", "registry"): + states = machine[key]["states"] + for e in machine[key]["edges"]: + check(e["from"] in states or e["from"] == "start", f"{key} edge {e['id']!r} starts at unknown state {e['from']!r}") + check(e["to"] in states, f"{key} edge {e['id']!r} ends at unknown state {e['to']!r}") + if e.get("by"): + check(e["by"] in skill_dirs, f"{key} edge {e['id']!r} names unknown skill {e['by']!r}") +same_set("no-orchestrator table", {r[0] for r in data["standalone"]}, skill_dirs) +for name, status, _ in data["standalone"]: + check(status in {"works", "degrades", "unavailable", "idle"}, f"no-orchestrator table: {name!r} has unknown status {status!r}") + +# Project file: every key in the example is documented in Project_File.md's +# quick reference, every artifact id is one the handbook lists for that group's +# language, and every section anchor the page links to exists. +handbook = (ROOT / "handbooks" / "Project_File.md").read_text(encoding="utf-8") +quick = handbook.split("## Quick Reference", 1)[1].split("\n## ", 1)[0] +documented = {re.sub(r"(\[\])+$", "", m) for m in re.findall(r"^\| `([^`]+)`", quick, re.M)} + + +def flatten(obj: object, prefix: str = "") -> set[str]: + keys: set[str] = set() + if isinstance(obj, dict): + for k, v in obj.items(): + path = f"{prefix}.{k}" if prefix else k + keys.add(path) + keys |= flatten(v, path) + elif isinstance(obj, list): + for item in obj: + keys |= flatten(item, f"{prefix}[]") + return keys + + +pf = data["projectFile"] +sample_keys = flatten(pf["sample"]) +# An intermediate object (tenancy.tenant) counts when its fields are documented. +for k in sorted(k for k in sample_keys - documented if not any(d.startswith(k + ".") for d in documented)): + errors.append(f"project file example uses {k!r}, which Project_File.md's quick reference does not document") +same_set("project file key notes", set(pf["keys"]), sample_keys) +for k, _, _ in pf["more"]: + check(k in documented, f"project file 'not in the example' names {k!r}, which the quick reference does not document") + + +def slug(heading: str) -> str: + text = re.sub(r"[^\w\- ]", "", heading.strip().lower()) + return text.replace(" ", "-") + + +anchors = {slug(h) for h in re.findall(r"^#{1,4} (.+)$", handbook, re.M)} +cited = [k["sec"] for k in pf["keys"].values()] + [a for _, a, _ in pf["more"]] + [a for *_, a in pf["footguns"]] +for a in sorted(set(cited)): + check(a in anchors, f"project file view links Project_File.md#{a}, which is not a heading there") + +lang_sections = {"c#": "### 11.1", "csharp": "### 11.1", "python": "### 11.2", "dart": "### 11.3", "flutter": "### 11.3", "markdown": "### 11.5"} +for g in pf["sample"]["groups"]: + head = lang_sections.get(g["language"].lower()) + if not head: + errors.append(f"project file example group {g['name']!r} has a language the check cannot map: {g['language']!r}") + continue + body = handbook.split(head, 1)[1].split("\n### 11.", 1)[0] + for art in g.get("artifacts", []): + check(f"`{art}`" in body, f"project file example group {g['name']!r} lists artifact {art!r}, not in Project_File.md {head[4:]}") + +for ch in data["loop"]["choices"]: + for step in ch["steps"]: + if step.startswith("/specfuse-authoring:"): + check(step.split(":", 1)[1] in skill_dirs, f"loop choice {ch['q']!r} names unknown skill {step!r}") + +# Backlog states: "State: `idea` → `shaping` → … (or `delivered` / `parked` / `dropped`)". +backlog = (ROOT / "templates" / "initiative-backlog.template.md").read_text(encoding="utf-8") +state_line = next((para.replace("\n", " ") for para in backlog.split("\n\n") if para.startswith("State: `")), "") +state_line = state_line.split(". ", 1)[0] +check(bool(state_line), "backlog template has no 'State: `…`' line") +source_states = re.findall(r"`([a-z]+)`", state_line) +check(data["backlogStates"] == source_states, + f"backlog states {data['backlogStates']} != template {source_states}") +side = re.search(r"\(or (.*?)\)", state_line) +check(bool(side) and set(data["backlogSide"]) == set(re.findall(r"`([a-z]+)`", side.group(1))), + "backlogSide does not match the template's '(or …)' states") + +# Readiness checklist. +dossier = (ROOT / "templates" / "initiative-idea-dossier.template.md").read_text(encoding="utf-8") +section = dossier.split("## Readiness", 1)[1].split("\n## ", 1)[0] if "## Readiness" in dossier else "" +source_ready = re.findall(r"^- \[ \] (.+)$", section, re.M) +check(data["readiness"] == source_ready, f"readiness {data['readiness']} != dossier template {source_ready}") + +# Validation layers, in order. +suite = (ROOT / "templates/project-init/scripts/specfuse/validate-specs.sh").read_text(encoding="utf-8") +block = re.search(r"LAYERS=\((.*?)\n\)", suite, re.S) +source_layers = re.findall(r'"([^"]+?)::\$SCRIPT_DIR/([\w.-]+)', block.group(1)) if block else [] +page_layers = [(l["label"], l["script"]) for l in data["layers"]] +check(page_layers == source_layers, f"validation layers {page_layers} != validate-specs.sh {source_layers}") +for mk in data["mistakes"]: + for i in mk["hit"] + mk.get("cascade", []): + check(0 <= i < len(data["layers"]), f"mistake {mk['t']!r} points at layer {i}, which does not exist") + +rulesets = "".join(p.read_text(encoding="utf-8") for p in (ROOT / "schemas" / "spectral").glob("specfuse-*.yaml")) +for mk in data["mistakes"]: + if rule := mk.get("rule"): + check(re.search(rf"^ {re.escape(rule)}:$", rulesets, re.M) is not None, + f"mistake {mk['t']!r} cites Spectral rule {rule!r}, which no kit ruleset defines") + +# Upgrade overlay and the simulator's quoted output. +same_set("upgrade overlay", set(data["overlay"]), {dest for _, dest in scaffold.OVERLAY}) +# Join implicitly concatenated literals ("…locally-"\n f"modified…") so quoted output matches. +scaffold_src = re.sub(r'"\s*\n\s*f?"', "", Path(scaffold.__file__).read_text(encoding="utf-8")) +for key, text in data["warnings"].items(): + check(text in scaffold_src, f"simulator quotes {text!r} ({key}), which scaffold.py no longer prints") + +# Links: relative ones must resolve here and in an installed project. +hrefs = re.findall(r'link\(\[\s*"[^"]*",\s*"([^"]+)"', html) +hrefs += re.findall(r'href="([^"$]+)"', html) +hrefs += [h for p in data["planes"] for _, h in p["links"]] +hrefs += [n["link"][1] for n in data["tree"] if "link" in n] +installed = {PurePosixPath(rel) for rel, _ in scaffold.overlay_files()} +for href in sorted(set(hrefs)): + if href.startswith(("http://", "https://", "#")): + continue + path = href.split("#", 1)[0] + check((PAGE.parent / path).resolve().is_file(), f"link {href!r} does not resolve from docs/") + parts: list[str] = [] + for part in (INSTALLED_PAGE.parent / path).parts: + if part == "..": + parts.pop() + else: + parts.append(part) + check(PurePosixPath(*parts) in installed, + f"link {href!r} would be broken in an installed project: the kit does not ship {PurePosixPath(*parts)}") + +if errors: + print("docs/flows.html has drifted from the kit:", file=sys.stderr) + for e in errors: + print(f" - {e}", file=sys.stderr) + sys.exit(1) +print(f"docs/flows.html matches the kit ({len(skill_dirs)} skills, {len(agent_files)} agents, " + f"{len(source_layers)} validation layers, {len(data['overlay'])} overlay entries).") diff --git a/specfuse/authoring/scaffold.py b/specfuse/authoring/scaffold.py index 7c3407e..08b1ae7 100644 --- a/specfuse/authoring/scaffold.py +++ b/specfuse/authoring/scaffold.py @@ -23,8 +23,8 @@ Without the manifest all three look the same on disk, and the only safe behaviours are "clobber everything" or "touch nothing". -What the overlay owns is kit content only: the handbooks, samples and schemas -under `.specfuse/authoring/`, plus the tooling at `scripts/specfuse/`. Note the +What the overlay owns is kit content only: the handbooks, samples, schemas and +flows page under `.specfuse/authoring/`, plus the tooling at `scripts/specfuse/`. Note the subdirectory — `scripts/` itself is the project's, and hosts tooling from other contracts (the shared substrate's `validate-event.py`, authoring #26). Prune is scoped to `scripts/specfuse/`, so a kit upgrade cannot reach them. @@ -83,6 +83,9 @@ "templates/initiative-idea-dossier.template.md", ".specfuse/authoring/templates/initiative-idea-dossier.template.md", ), + # The interactive flows page. Only this one file from docs/ ships: the rest + # of docs/ is kit-repo documentation, and the page links to it on GitHub. + ("docs/flows.html", ".specfuse/authoring/docs/flows.html"), ("templates/project-init/scripts/specfuse", "scripts/specfuse"), )