diff --git a/.cursor/agents/flatbread-contract-drift-hunter.md b/.cursor/agents/flatbread-contract-drift-hunter.md new file mode 100644 index 00000000..c418b01c --- /dev/null +++ b/.cursor/agents/flatbread-contract-drift-hunter.md @@ -0,0 +1,45 @@ +--- +name: flatbread-contract-drift-hunter +description: Read-only reviewer for public contract drift across Flatbread code, docs, exports, examples, and tests. +readonly: true +tools: ReadFile, Glob, rg, Shell +--- + +# Flatbread Contract Drift Hunter + +You review changes like a maintainer worried the public contract is already drifting. Assume README text, exported helpers, proposal docs, examples, and tests disagree unless verified. + +## Bias + +- Public behavior matters more than internal neatness. +- A feature is not "landed" if the docs, exports, and validation story lag behind the code. +- Generated or supporting surfaces that stop matching runtime count as regressions. + +## Focus + +- Drift between changed source files, package READMEs, proposal docs, examples, tests, and any exposed API. +- Whether new or changed behavior is teachable with the repo's documented commands and conventions. +- Whether exported symbols, schemas, CLI surfaces, and examples make the change easier to adopt correctly. +- Whether test placement and docs protect the contract from future regressions. + +## Output + +Lead with contract drift and missing adoption surfaces. Ignore pure style unless it changes what contributors or users can rely on. + +## Output Schema For DAG Handoff + +Use these exact headings: + +``` +## Persona +## Bias +## Blockers +## High-severity findings +## Medium-severity findings +## Low-severity findings +## Residual risk +## Recommended next DAG tasks +``` + +Each finding is one bullet: `path/to/file:line — drift -> minimal fix`. +Keep the response under ~1800 chars when used inside a DAG. diff --git a/.cursor/agents/flatbread-devex-curmudgeon.md b/.cursor/agents/flatbread-devex-curmudgeon.md new file mode 100644 index 00000000..18c60484 --- /dev/null +++ b/.cursor/agents/flatbread-devex-curmudgeon.md @@ -0,0 +1,45 @@ +--- +name: flatbread-devex-curmudgeon +description: Read-only reviewer for contributor friction in Flatbread commands, error messages, docs, and local workflows. +readonly: true +tools: ReadFile, Glob, rg, Shell +--- + +# Flatbread DevEx Curmudgeon + +You review changes like an impatient contributor on a bad day. Assume every extra flag, hidden prerequisite, unclear error, or doc gap will be hit at 2 AM by someone who did not author the feature. + +## Bias + +- Optimize for shortest path from "I want to use this" to "it worked". +- Prefer self-describing config over remembered CLI incantations. +- Treat missing docs, misleading comments, and non-obvious verification commands as product bugs. + +## Focus + +- Local dev loop ergonomics for changed packages, CLIs, examples, and contributor workflows. +- Whether package README, proposal docs, and inline comments match actual behavior. +- Whether commands are discoverable from the repo root and whether failures explain how to recover. +- Whether tests live where repo tooling will actually run them. + +## Output + +Lead with the friction that would waste contributor time. Favor fixes that reduce cognitive load, not just raw correctness. + +## Output Schema For DAG Handoff + +Use these exact headings: + +``` +## Persona +## Bias +## Blockers +## High-severity findings +## Medium-severity findings +## Low-severity findings +## Residual risk +## Recommended next DAG tasks +``` + +Each finding is one bullet: `path/to/file:line — friction -> minimal fix`. +Keep the response under ~1800 chars when used inside a DAG. diff --git a/.cursor/agents/flatbread-devils-advocate.md b/.cursor/agents/flatbread-devils-advocate.md new file mode 100644 index 00000000..7aa264d9 --- /dev/null +++ b/.cursor/agents/flatbread-devils-advocate.md @@ -0,0 +1,45 @@ +--- +name: flatbread-devils-advocate +description: Read-only skeptic who argues the change should be smaller, later, or not shipped unless the repo proves the complexity is worth it. +readonly: true +tools: ReadFile, Glob, rg, Shell +--- + +# Flatbread Devil's Advocate + +You are not trying to be fair. Your job is to stress-test whether a feature should exist in its current shape at all. Assume every new knob, export, and concept is guilty until the repo proves it buys enough leverage to justify the maintenance cost. + +## Bias + +- Prefer deleting surface area over documenting it. +- Prefer one obvious path over flexible-but-fragile configuration. +- Treat "future extensibility" as suspicious unless current users clearly benefit now. + +## Focus + +- Whether the proposed public surface is the smallest viable API for the problem. +- Whether each new option, mode, export, or workflow pays for its complexity today, or should be narrowed further. +- Whether this branch adds concepts faster than Flatbread contributors can internalize them. +- Whether a narrower implementation would preserve DevEx better. + +## Output + +Attack the premise, API size, and rollout story. If you think the feature should still ship, say why the complexity is barely justified and what guardrails are still missing. + +## Output Schema For DAG Handoff + +Use these exact headings: + +``` +## Persona +## Bias +## Blockers +## High-severity findings +## Medium-severity findings +## Low-severity findings +## Residual risk +## Recommended next DAG tasks +``` + +Each finding is one bullet: `path/to/file:line — complexity cost -> minimal fix`. +Keep the response under ~1800 chars when used inside a DAG. diff --git a/.cursor/agents/flatbread-proof-runtime-skeptic.md b/.cursor/agents/flatbread-proof-runtime-skeptic.md new file mode 100644 index 00000000..c9e5a9f5 --- /dev/null +++ b/.cursor/agents/flatbread-proof-runtime-skeptic.md @@ -0,0 +1,46 @@ +--- +name: flatbread-proof-runtime-skeptic +description: Read-only reviewer for Proof runtime invariants, loop semantics, resume/restart behavior, and failure-mode ergonomics. +readonly: true +tools: ReadFile, Glob, rg, Shell +--- + +# Flatbread Proof Runtime Skeptic + +You review `@flatbread/proof` like a failure analyst. Assume orchestration logic, task ordering, resume/restart boundaries, and budget semantics are wrong until the code and tests prove otherwise. + +## Bias + +- Prefer boring runtime behavior over clever API surface. +- Treat hidden state, precedence rules, and partial reruns as high risk. +- Treat confusing logs, canvas states, or restart semantics as DevEx bugs, not documentation nits. + +## Focus + +- Runtime correctness for DAG execution, especially dependency ordering, rank behavior, partial reruns, and terminal outcomes. +- Interaction of DAG schema, CLI flags, persisted state, sidecar artifacts, and self-hosting restarts. +- Whether tests prove the runtime contract contributors will depend on. +- Whether a contributor debugging a bad proof run would get actionable evidence. +- Prefer the focused proof suite command `pnpm -F @flatbread/proof test` when validating proof runtime behavior; root `pnpm test` should also cover it. + +## Output + +Lead with findings, ordered by severity. Prefer concrete runtime breakage, observability gaps, and validation holes over stylistic commentary. + +## Output Schema For DAG Handoff + +Use these exact headings: + +``` +## Persona +## Bias +## Blockers +## High-severity findings +## Medium-severity findings +## Low-severity findings +## Residual risk +## Recommended next DAG tasks +``` + +Each finding is one bullet: `path/to/file.ts:line — risk -> minimal fix`. +Keep the response under ~1800 chars when used inside a DAG. diff --git a/.cursor/rules/proof-usage-guardrails.mdc b/.cursor/rules/proof-usage-guardrails.mdc new file mode 100644 index 00000000..5408ffbe --- /dev/null +++ b/.cursor/rules/proof-usage-guardrails.mdc @@ -0,0 +1,21 @@ +--- +description: Proof DAG guardrails for one-shot verification commands +alwaysApply: true +--- + +# Proof Usage Guardrails + +When using `/proof` or authoring a DAG for `@flatbread/proof` in this repo: + +- Write verifier/test-task prompts so they require **one-shot commands only**. Say this explicitly in the prompt. +- Before using a package `test` script in a verifier task, check whether it is watch mode in that package's `package.json` or in `AGENTS.md`. +- Never use bare `vitest` in Proof verification tasks. Use `vitest run` via the package manager instead. +- Avoid generic commands like `pnpm --filter test` when that package's `test` script may watch. Prefer explicit one-shot commands. +- Repo-specific safe examples: + - `pnpm exec ava ` + - `pnpm -F @flatbread/codegen exec vitest run` + - `pnpm -F @flatbread/utils exec vitest run` + - `pnpm -F @flatbread/proof test` +- In verification/review rungs, prefer naming the exact commands to run instead of saying "run relevant tests". +- If a Proof verification task accidentally starts a watcher or other non-terminating process, stop it, treat that as a prompt bug, and rerun with an explicit one-shot command. +- If Proof-related work changes rules, docs, skills, prompts, or runtime behavior, update the authoritative Proof guidance sources, run `pnpm -F @flatbread/proof build`, and rerun `pnpm exec proof setup` so the owned-guidelines bundle and manifest do not self-stale through stale packaged setup code. diff --git a/.cursor/skills/proof/SKILL.md b/.cursor/skills/proof/SKILL.md index 5d26228f..20fb3ea5 100644 --- a/.cursor/skills/proof/SKILL.md +++ b/.cursor/skills/proof/SKILL.md @@ -19,6 +19,16 @@ Trigger when the user says any of: Skip when the task is a single-shot edit, a quick question, or already linear enough that one agent turn would handle it. +## Repo-specific setup guidance + +In this repo, `proof setup` owns the derived Proof guidelines bundle used to keep Proof-specific rules, docs, skills, prompts, and runtime behavior in sync. If Proof-related work changes those authoritative guidance sources or setup/runtime behavior, update them and rerun: + +```bash +pnpm -F @flatbread/proof build && pnpm exec proof setup +``` + +This prevents future Proof DAG prompts from using stale owned-guidelines artifacts or stale packaged setup code. + ## Workflow ### Step 1 — Generate a DAG JSON diff --git a/.flatbread/proof/setup/owned-guidelines.bundle.md b/.flatbread/proof/setup/owned-guidelines.bundle.md new file mode 100644 index 00000000..fe1ec046 --- /dev/null +++ b/.flatbread/proof/setup/owned-guidelines.bundle.md @@ -0,0 +1,708 @@ +# Owned Proof Guidelines Bundle + +This file is derived by `pnpm exec proof setup`. Do not hand-edit it; edit the source files listed below instead. + +## Maintenance Contract + +- If Proof-related work changes rules, docs, skills, prompts, or runtime behavior, update the authoritative source files, run `pnpm -F @flatbread/proof build`, and rerun `pnpm exec proof setup` before concluding so the owned-guidelines bundle and manifest do not go stale. +- This derived bundle lives at `.flatbread/proof/setup/owned-guidelines.bundle.md` and is only trustworthy when its manifest matches the current source files. +- Treat the source files as authoritative when reconciling conflicts between this bundle and the repo. + +## Included Sources + +- `.cursor/rules/proof-usage-guardrails.mdc` (workspace-rule, sha256=e0c1aca4f65a87de7151f399b79bcf16df7ae83e96c6d098ab98cf57ec3a602d) +- `AGENTS.md` (workspace-contract, sha256=f7b38cb7d9b82ed2384f6c2f4bc5ae6f5cfe35d86712215650326115bdd7b76c) +- `packages/proof/README.md` (package-readme, sha256=fe2a8378e21fe8b3ea32600c5cf9f7f82d095085ce3229efe9bf9a06f858fa38) +- `.cursor/skills/proof/SKILL.md` (skill, sha256=fb8b12568afc9c42175412af7d5d61d9904fc895eb0cb3268e6e29cf5d3bf43d) +- `.cursor/skills/dag-task-runner/SKILL.md` (skill, sha256=8e3071f34dedf182a8cebd763e84bae5144d99d5758d3edf815ed6051f8baac1) + +## Missing Expected Sources + +_None._ + +## Source: `.cursor/rules/proof-usage-guardrails.mdc` + +Category: workspace-rule + +```md +--- +description: Proof DAG guardrails for one-shot verification commands +alwaysApply: true +--- + +# Proof Usage Guardrails + +When using `/proof` or authoring a DAG for `@flatbread/proof` in this repo: + +- Write verifier/test-task prompts so they require **one-shot commands only**. Say this explicitly in the prompt. +- Before using a package `test` script in a verifier task, check whether it is watch mode in that package's `package.json` or in `AGENTS.md`. +- Never use bare `vitest` in Proof verification tasks. Use `vitest run` via the package manager instead. +- Avoid generic commands like `pnpm --filter test` when that package's `test` script may watch. Prefer explicit one-shot commands. +- Repo-specific safe examples: + - `pnpm exec ava ` + - `pnpm -F @flatbread/codegen exec vitest run` + - `pnpm -F @flatbread/utils exec vitest run` + - `pnpm -F @flatbread/proof test` +- In verification/review rungs, prefer naming the exact commands to run instead of saying "run relevant tests". +- If a Proof verification task accidentally starts a watcher or other non-terminating process, stop it, treat that as a prompt bug, and rerun with an explicit one-shot command. +- If Proof-related work changes rules, docs, skills, prompts, or runtime behavior, update the authoritative Proof guidance sources, run `pnpm -F @flatbread/proof build`, and rerun `pnpm exec proof setup` so the owned-guidelines bundle and manifest do not self-stale through stale packaged setup code. +``` + +## Source: `AGENTS.md` + +Category: workspace-contract + +```md +# Agents + +## Cursor Cloud specific instructions + +### Overview + +Flatbread is Git-native **relational content for TypeScript/JavaScript apps**: flat files become a typed graph; **GraphQL is one read surface**, not the whole product. It's a pnpm monorepo. See `CONTRIBUTING.md` for the canonical onboarding path. + +### Key commands + +See `CONTRIBUTING.md` for full details. Quick reference: + +- **Install**: `pnpm install` (enforces pnpm via `preinstall` script) +- **Build**: `pnpm build` (builds all packages except examples via tsup) +- **Lint**: `pnpm lint` (prettier) +- **Lint fix (after edits)**: `pnpm lint:fix:fast` (writes formatting repo-wide to match `pnpm lint`; staged-only: `pnpm lint:fix`, also runs via `.husky/pre-commit`) +- **Typecheck**: `pnpm typecheck` +- **Test**: `pnpm test` (builds, then runs ava + vitest suites, including `@flatbread/proof` bounded-loop coverage). For the focused proof loop suite: `pnpm -F @flatbread/proof test`. Vitest packages use `pnpm -F @flatbread/utils exec vitest run` / `pnpm -F @flatbread/codegen exec vitest run` (`run` avoids watch mode). +- **Full verify**: `pnpm verify` (lint + typecheck + build + test) +- **Proof loop contract**: explicit `DAG.loops[].reexecute.tasks` subsets must be dependency-closed, multiple loops must have disjoint re-execution sets, and `DAG.loops` must not be combined with `--converge-on`. +- **Dev server**: `pnpm play` (GraphQL on port 5057, Next.js on port 3000). From `examples/nextjs`, prefer `pnpm exec flatbread start -- next dev --turbopack`. Use `flatbread start` — `flatbread dev` is not a CLI command. + +### Mergify Stacks + +The repo uses Mergify stacks for PR management. The `mergify-cli` is installed via `pip install mergify-cli` (included in the update script). Key points: + +- Use `mergify stack push` instead of `git push` on feature branches (the `.husky/pre-push` hook will remind you). +- The commit-msg hook (`.husky/commit-msg`) auto-appends a `Change-Id` trailer for stack tracking. +- See `.agents/skills/mergify-stack/SKILL.md` for the full workflow. + +### Gotchas + +- **`@flatbread/proof` requires `CURSOR_RIPGREP_PATH`.** The proof package uses `@cursor/sdk` which expects a bundled ripgrep. In Cloud Agent VMs, set `export CURSOR_RIPGREP_PATH=/usr/bin/rg` to use the system ripgrep (included in the update script). +- **Native build scripts are approved in `pnpm-workspace.yaml`.** The `onlyBuiltDependencies` list allows esbuild, sharp, @swc/core, etc. to run their postinstall scripts automatically during `pnpm install`. +- **Vitest packages run in watch mode by default.** Always use `vitest run` (not bare `vitest`) to get a single run and exit. +- **`flatbread` CLI is not on PATH globally.** From `examples/nextjs`, prefer `pnpm exec flatbread …` (local binary), or `npx flatbread` from a shell. The `pnpm play` script from the root handles this automatically. +- **Build before test.** All packages must be built (`pnpm build`) before running tests or starting dev servers. `pnpm test` handles this automatically. +- **The Next.js example `dev` script uses `--https`.** This requires an SSL certificate. In headless/CI environments, run without `--https`: `pnpm exec flatbread start -- next dev --turbopack`. +- **Full local CI parity check:** `pnpm verify` runs lint, typecheck, build, and all tests. + +### Weave merge driver + +The repo uses [weave](https://ataraxy-labs.github.io/weave/docs.html) for entity-level semantic merges. The `.gitattributes` file routes supported file types (`.ts`, `.js`, `.json`, `.md`, `.yaml`, etc.) through `weave-driver`, which resolves merges at the function/class/entity level instead of line-by-line. + +- **Binaries**: `weave` (CLI) and `weave-driver` (git merge driver), installed via `cargo install --git https://github.com/Ataraxy-Labs/weave weave-cli weave-driver`. The update script handles this. +- **Preview a merge**: `weave preview ` — dry-run that shows which files/entities would merge cleanly or conflict. +- **Config**: `weave setup` was already run; git config `merge.weave.driver` points to `weave-driver`. No re-run needed unless the binary path changes. +- **Rust toolchain**: Weave requires Rust >= 1.89. The update script ensures `rustup default stable` is set. +``` + +## Source: `packages/proof/README.md` + +Category: package-readme + +````md +# Proof + +Proof is Flatbread's DAG task runner for Cursor agents. It decomposes a task into a graph of subagents, runs each node in topological order, and writes a live `.canvas.tsx` so you can watch the work move from `PENDING` to `RUNNING` to `FINISHED` or `ERROR`. + +The package ships as `@flatbread/proof` and exposes: + +- `proof`: run a DAG, initialize its canvas, or generate `proof setup` artifacts. +- `proof-supervisor`: run Proof in self-hosting mode so edits to `packages/proof/src/**` can be picked up between ranks. +- Library exports for tooling that wants to author, validate, or inspect DAGs programmatically. + +## Quick Start + +Build the package once after installing dependencies: + +```bash +pnpm -F @flatbread/proof build +``` +```` + +Create a DAG JSON file: + +```json +{ + "title": "Build a tiny CLI todo app", + "tasks": [ + { + "id": "design", + "depends_on": [], + "complexity": "LOW", + "subtask_prompt": "Design the minimal CLI commands and file layout." + }, + { + "id": "implement", + "depends_on": ["design"], + "complexity": "MED", + "subtask_prompt": "Implement the todo CLI based on the design." + } + ] +} +``` + +Initialize a canvas without requiring `CURSOR_API_KEY`: + +```bash +pnpm exec proof \ + --init-only \ + --dag /tmp/example-dag.json \ + --canvas-path /tmp/example-dag.canvas.tsx +``` + +Run the DAG: + +```bash +export CURSOR_API_KEY=crsr_... + +pnpm exec proof \ + --dag /tmp/example-dag.json \ + --canvas-path /tmp/example-dag.canvas.tsx +``` + +## `proof setup` + +`proof setup` prepares repo-owned Proof guidance without launching agents by default: + +```bash +pnpm exec proof setup +``` + +Default output lives under `/.flatbread/proof/setup/`: + +```text +owned-guidelines.bundle.md +owned-guidelines.manifest.json +setup-dag.json +setup-summary.md +``` + +Behavior: + +- Reuses the existing owned-guidelines bundle + manifest when the owned Proof guidance sources are still fresh. +- Regenerates them when Proof guidance sources changed. +- Computes setup gaps and writes a runnable setup DAG + summary, but does not launch agents unless you opt in. +- When the generated DAG does launch agents, it inserts an explicit post-edit refresh step before review; that step rebuilds `@flatbread/proof` and then reruns `proof setup` so runtime edits do not regenerate artifacts through an old packaged CLI. +- Bakes in an explicit maintenance contract: if Proof-related work changes rules, docs, skills, prompts, or runtime behavior, update the owned source files, rebuild `@flatbread/proof`, and rerun `proof setup` so the derived bundle does not become stale. + +Opt in to handing the generated DAG to the existing runner: + +```bash +pnpm exec proof setup --run-agents --canvas proof-setup +``` + +The authoritative owned guidance sources currently include `AGENTS.md`, `.cursor/rules/proof-usage-guardrails.mdc`, `packages/proof/README.md`, `.cursor/skills/proof/SKILL.md`, and the legacy `.cursor/skills/dag-task-runner/SKILL.md` compatibility handoff so reruns notice stale redirects too. + +## DAG Shape + +Every DAG has a `title` and a `tasks` array. Each task needs: + +- `id`: unique kebab-case task id. +- `depends_on`: ids of parent tasks that must finish first. +- `complexity`: `HIGH`, `MED`, or `LOW`; maps to a Cursor model. +- `subtask_prompt`: standalone instructions for the subagent. + +Proof computes ranks with Kahn topological sort and runs sibling tasks in the same rank concurrently. Avoid placing two sibling tasks in the same rank if they write the same files. + +Optional top-level `models` can override the default complexity map with plain +SDK model id strings or SDK model selections: + +```json +{ + "models": { + "HIGH": { + "id": "gpt-5.4", + "params": [{ "id": "reasoning", "value": "high" }] + }, + "MED": "composer-2", + "LOW": { + "id": "gpt-5.4-nano", + "params": [{ "id": "reasoning", "value": "low" }] + } + } +} +``` + +Use the object shape when you need `params`; use a string when the model id is +enough. For example, use `{ "id": "gpt-5.4", "params": [{ "id": "reasoning", "value": "high" }] }`, not a suffix-style id like `gpt-5.4-high`. + +When a DAG runs, Proof calls `Cursor.models.list()`, validates model ids and +param values, and expands partial selections to the closest valid SDK preset +variant using that model's default variant for omitted params. `--init-only` +does not call the SDK, so it can still render a canvas without `CURSOR_API_KEY`. + +Optional task kinds add control gates: + +- `kind: "oracle"` runs a shell command and records pass/fail evidence. +- `kind: "pause"` waits for a checkpoint sentinel so a human can inspect or approve before downstream work continues. + +## `DAG.loops` + +Bounded convergence loops can live in the DAG itself instead of only on the CLI. This keeps the run reproducible: contributors do not need to remember a matching `--converge-on ... --max-iterations ...` flag pair. + +```json +{ + "title": "implement then review until clean", + "loops": [ + { + "convergeOn": "review", + "maxIterations": 3, + "reexecute": { "kind": "tasks", "tasks": ["implement"] } + } + ], + "tasks": [ + { + "id": "implement", + "depends_on": [], + "complexity": "MED", + "subtask_prompt": "Implement the feature." + }, + { + "id": "review", + "depends_on": ["implement"], + "complexity": "HIGH", + "subtask_prompt": "Review the implementation. Use `## Blockers` and `## High-severity findings` when needed." + } + ] +} +``` + +Notes: + +- Omit `id` to get the default `loop-` id. +- Omit `reexecute` to re-run the full ancestor cone, which matches the legacy CLI behavior. +- `reexecute: { "kind": "tasks", "tasks": [...] }` must stay inside the convergence task's ancestor cone and be dependency-closed for every non-`convergeOn` task it names; invalid subsets fail fast during DAG parsing with the missing ancestor ids. +- Parsed explicit rerun lists always include `convergeOn` itself, even if the authored JSON omits it. +- `DAG.loops` and `--converge-on` are mutually exclusive. If the DAG already declares loops, remove the CLI flag instead of relying on precedence. +- Multiple loops are allowed only when their re-execution sets are disjoint, so one loop cannot invalidate another loop's converged result later in the run. + +## Artifact Output + +By default, every **full DAG run** writes per-task markdown transcripts to a timestamped directory (not `--init-only`, which exits before artifact setup, and not `--dry-check-cmds`, which never enters the runner): + +``` +/.flatbread/artifacts/dag--/ + _dag.json # The original DAG definition + _index.md # Run summary: outcome, timings, and links to all transcripts + .md # Full agent output for each task (kind: task, oracle, or pause) +``` + +Paths resolve from `--cwd` (defaults to the process working directory). The live canvas still defaults under `~/.cursor/projects//canvases/` when using `--canvas` without `--canvas-path`. + +Previously, transcripts only appeared when you passed `--full-output-dir`; now they land under `.flatbread/` by default. Use `--no-artifacts` for opt-out, or `--full-output-dir` to redirect elsewhere. + +`--no-artifacts` suppresses transcripts, `_index.md`, and `_dag.json` only. **`--findings-dir` JSON sidecars use a separate path** — omit that flag (or point it elsewhere) if you need completely artifact-free output besides the canvas. + +To suppress artifact writing: + +```bash +pnpm exec proof --dag /tmp/my.json --canvas-path /tmp/my.canvas.tsx --no-artifacts +``` + +To write artifacts to a custom path: + +```bash +pnpm exec proof --dag /tmp/my.json --canvas-path /tmp/my.canvas.tsx \ + --full-output-dir /path/to/my-artifacts/ +``` + +## Project Skill + +The canonical Cursor skill entrypoint lives at: + +```text +.cursor/skills/proof/SKILL.md +``` + +Use that skill when a request asks to decompose work, run subagents in parallel, or execute a task as a dependency graph. The legacy `.cursor/skills/dag-task-runner/SKILL.md` entry remains as a compatibility handoff, points to Proof, and is also tracked by `proof setup` as repo-owned guidance. + +## Self-Hosting Mode + +When the DAG may edit Proof itself, use the supervisor: + +```bash +pnpm exec proof-supervisor \ + --dag /tmp/example-dag.json \ + --canvas-path /tmp/example-dag.canvas.tsx \ + --state-path /tmp/example-dag-state.json +``` + +The supervisor adds `--restart-on-runner-change`. If runtime files change after a rank, Proof persists state, exits with code `75`, and the supervisor resumes from the state file under the rebuilt runtime. + +Each supervisor-spawned runner picks a **new default** `.flatbread/artifacts/dag--/` directory unless you pin **`--full-output-dir ` on the supervisor command** so every child inherits the same path. + +After editing `packages/proof/src/**`, rebuild before resuming packaged CLI runs: + +```bash +pnpm -F @flatbread/proof build +``` + +## Useful Commands + +```bash +pnpm -F @flatbread/proof typecheck +pnpm -F @flatbread/proof build +pnpm -F @flatbread/proof test +pnpm -F @flatbread/proof build && pnpm exec proof setup +pnpm test +pnpm -F @flatbread/proof models:list +pnpm exec proof --dry-check-cmds --dag .cursor/skills/proof/examples/example_dag.json +``` + +`pnpm -F @flatbread/proof test` is the focused bounded-loop suite. Root `pnpm test` also reaches that AVA file through `ava.config.js`. + +## Library API + +Proof also exposes helpers for tooling: + +```ts +import { + computeRanks, + createModelSelectionResolver, + parseDAG, + resolveModelSelectionFromCatalog, + runDryCheck, + type DAG, + type TaskState, +} from '@flatbread/proof'; +``` + +The public API includes DAG parsing and rank computation, model resolution, canvas state types, convergence helpers, dry command checks, oracle and pause helpers, and self-hosting state utilities. + +```` + +## Source: `.cursor/skills/proof/SKILL.md` + +Category: skill + +```md +--- +name: proof +description: Decompose a user's task into a DAG of subtasks and execute them with Cursor SDK local subagents in topological order, rendering live streaming status to a canvas. Each task has a complexity (HIGH/MED/LOW) that maps to a model. Use when the user asks to fan out work, decompose a task into a DAG, run subagents in parallel, or break a large task into a dependency graph. +--- + +# Proof + +Decomposes a user-described task into a JSON DAG, then runs each node as a Cursor SDK local subagent (with parents' outputs stitched into the child's prompt). Live DAG state — including each running subagent's streaming output — is rendered into a `.canvas.tsx` that the runner rewrites on every status transition; the IDE hot-recompiles so the user sees subagents move through `PENDING -> RUNNING -> FINISHED/ERROR` in real time. + +The runtime ships as the workspace package `@flatbread/proof` (`packages/proof`). It exposes two CLIs — `proof` (runner) and `proof-supervisor` (self-hosting wrapper) — plus a public library API for tooling that wants to author or inspect DAGs programmatically. + +## When to use + +Trigger when the user says any of: + +- "decompose this task", "break this into a DAG", "fan out subagents" +- "run this as a graph of subtasks" +- a multi-step request where some steps clearly depend on others and others can run in parallel + +Skip when the task is a single-shot edit, a quick question, or already linear enough that one agent turn would handle it. + +## Repo-specific setup guidance + +In this repo, `proof setup` owns the derived Proof guidelines bundle used to keep Proof-specific rules, docs, skills, prompts, and runtime behavior in sync. If Proof-related work changes those authoritative guidance sources or setup/runtime behavior, update them and rerun: + +```bash +pnpm -F @flatbread/proof build && pnpm exec proof setup +```` + +This prevents future Proof DAG prompts from using stale owned-guidelines artifacts or stale packaged setup code. + +## Workflow + +### Step 1 — Generate a DAG JSON + +You (the parent agent) author the DAG inline using your understanding of the user's task. Schema: + +```json +{ + "title": "", + "models": { + "HIGH": { + "id": "gpt-5.4", + "params": [{ "id": "reasoning", "value": "high" }] + }, + "MED": "composer-2", + "LOW": { + "id": "gpt-5.4-nano", + "params": [{ "id": "reasoning", "value": "low" }] + } + }, + "tasks": [ + { + "id": "", + "depends_on": ["", "..."], + "complexity": "HIGH | MED | LOW", + "subtask_prompt": "" + } + ] +} +``` + +Rules: + +- Every `depends_on` entry must reference another task's `id`. +- No cycles. The runner rejects cyclic DAGs at parse time. +- `complexity` controls the model the subagent uses (see table below). Pick `HIGH` for novel/complex reasoning, `MED` for typical implementation, `LOW` for mechanical/lookup tasks. +- Optional top-level `models` can override the default complexity → model map for this DAG. Values can be plain SDK model id strings or model selection objects of the shape `{ "id": "...", "params": [{ "id": "...", "value": "..." }] }`, with `params` omitted when unused. +- `subtask_prompt` should read like a standalone request — the runner automatically prepends a short summary of upstream task outputs, so you do not need to repeat them. +- Do **not** put two tasks that write to the same file in the same rank (siblings within a rank run concurrently and would race). + +#### Maximize parallelism — this is the whole point of the runner + +The runner executes tasks within a rank **concurrently** via `Promise.all`. A linear `A → B → C → D` DAG wastes that capability. Before finalizing the DAG, actively decompose the problem to surface independent work: + +1. **Default to no dependencies.** Add a `depends_on` entry **only** when the child task literally cannot start without the parent's output. "Logically follows" is not a dependency. +2. **Split read-only research and discovery into a wide first rank.** Codebase grepping, doc reading, dependency scans, schema lookups, test inventory — these almost always share rank 1 with no edges between them. +3. **Fan out post-implementation work.** Tests, docs, changelog entries, type updates, lint fixes typically all depend on the same implementation task and on nothing else — put them in one rank, not a chain. +4. **Use diamonds, not lines.** If two tasks both feed into a third, model that explicitly: rank 1 has the two parents, rank 2 is the merge. +5. **Same-rank file-write safety.** The one hard constraint: don't put two tasks in the same rank if they would write the same file. Either serialize them with a `depends_on`, or merge them into one task. + +Quality bar: when you sketch the rank structure (rank 1 → rank 2 → …), at least one rank should contain more than one task in any non-trivial problem. If your DAG is a single chain of 1-task ranks, you almost certainly missed parallelism — go back and look again. + +The example shipped with the skill (`.cursor/skills/proof/examples/example_dag.json`) demonstrates the pattern: rank 1 fans out to two read-only research tasks, rank 2 merges them into a design, rank 3 implements, and rank 4 fans out again to tests + docs. + +Write the JSON to a temp file **and immediately generate the initial canvas** so the user can open it while subagents spin up. Run all of the following in a single shell block: + +```bash +# 0. Pick a canvas path +CANVAS_PATH="$HOME/.cursor/projects//canvases/dag-.canvas.tsx" + +# 1. Write the DAG JSON +cat > /tmp/dag-.json <<'JSON' +{ "title": "...", "tasks": [ ... ] } +JSON + +# 2. Build the @flatbread/proof package once per workspace install +# (skipped if dist/ is already present; safe to re-run). +[ -f "$(git rev-parse --show-toplevel)/packages/proof/dist/run_dag.js" ] || \ + pnpm -F @flatbread/proof build + +# 3. Generate the initial all-PENDING canvas (no CURSOR_API_KEY needed) +pnpm exec proof \ + --init-only \ + --dag /tmp/dag-.json \ + --canvas-path "$CANVAS_PATH" + +# 4. Best-effort auto-open of the canvas file; ignore failure in headless/non-macOS environments +open "$CANVAS_PATH" >/dev/null 2>&1 || true +``` + +The canvas path is: + +``` +~/.cursor/projects//canvases/dag-.canvas.tsx +``` + +`` is derived from the cwd's absolute path by stripping the leading `/`, replacing path separators with `-`, and sanitizing other non-alphanumeric characters within each path segment to `-`. Example: cwd `/Users/me/Code/myapp` → slug `Users-me-Code-myapp`. Use the same `` you used for the DAG JSON filename so they're easy to correlate. + +### Step 2 — Surface the canvas link in chat + +Now that the file exists on disk, post a Markdown hyperlink with the exact text `Open Canvas` and a `file://` URL, plus the absolute path for fallback: + +> I created a live canvas: [Open Canvas](file:///Users//.cursor/projects//canvases/dag-.canvas.tsx) +> Fallback path: `/Users//.cursor/projects//canvases/dag-.canvas.tsx` + +Always use the link text `Open Canvas`. Use the absolute path in both the `file://` URL and fallback path, never `~/`. Do this **before** Step 3 so the user can open the canvas while subagents are still spinning up. The Step 1 shell block already attempts to auto-open the canvas with `open`; if that fails, continue and rely on the chat link. + +### Step 3 — Run the DAG + +Ensure `CURSOR_API_KEY` is set (the runner fails fast if missing), then launch: + +```bash +[ -n "$CURSOR_API_KEY" ] || { [ -f .env ] && set -a && source .env && set +a; } + +pnpm exec proof \ + --dag /tmp/dag-.json \ + --canvas-path "$CANVAS_PATH" +``` + +If the DAG is expected to edit the runner itself (`packages/proof/src/**`), launch through the supervisor instead so source edits take effect at a process boundary: + +```bash +pnpm exec proof-supervisor \ + --dag /tmp/dag-.json \ + --canvas-path "$CANVAS_PATH" \ + --state-path "$HOME/.cursor/projects//dag-state/.json" +``` + +The supervisor passes `--restart-on-runner-change` to the runner. When runner runtime files change after a rank or convergence iteration, the child runner persists state, marks the canvas `RESTARTING RUNNER`, exits `75`, and the supervisor relaunches with `--resume-state` so pending tasks continue under the new source. After editing `packages/proof/src/**`, run `pnpm -F @flatbread/proof build` so the relaunch picks up the new code. + +Same `--canvas-path` as Step 1. The runner: + +1. Validates the DAG and reuses the existing canvas file. +2. For each rank (Kahn topo-sort), launches ready tasks concurrently as local Cursor SDK agents and rewrites the canvas as each one transitions, streaming assistant text into each task card live. +3. Automatically skips tasks whose upstream dependencies failed (marks them `ERROR` with a "Skipped: upstream task(s) … failed" message). +4. Captures each subagent's final assistant text, status, token usage, and duration. +5. Writes a final canvas with summary stats. +6. Artifact output (default, suppress with `--no-artifacts` or override path with `--full-output-dir`; skipped entirely for `--init-only` and `--dry-check-cmds`): + - **At run start:** writes `_dag.json` (the original DAG definition) to the artifacts directory. + - **As each task finishes:** writes `${taskId}.md` (full transcript for `kind: task`, `oracle`, and `pause`). + - **At run end:** best-effort `_index.md` (run summary table with timestamps, outcome, and per-task links for transcripts that exist); write failures are logged as `[proof]` warnings rather than crashing the runner. +7. On SIGINT/SIGTERM/SIGHUP, cancels all in-flight subagents before finalizing the canvas. + +#### CLI knobs + +| Flag | Default | Purpose | +| ------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--models-file ` | — | JSON file containing a partial complexity → model override map. | +| `--state-path ` | — | Persist resumable state after rank boundaries. | +| `--resume-state ` | — | Resume from a persisted state file. | +| `--restart-on-runner-change` | `false` | Exit `75` after runner runtime files change so a supervisor can relaunch. | +| `--task-timeout-ms ` | `1200000` (20 min) | Marks a task `ERROR` if it runs too long. | +| `--stream-publish-ms ` | `500` | Throttles live canvas streaming writes. | +| `--stream-idle-timeout-ms ` | `300000` (5 min) | Marks a task `ERROR` if no stream events arrive. | +| `--debounce ` | `200` | Canvas write debounce interval. | +| `--full-output-dir ` | computed default | Per-task transcripts + `_index.md` + `_dag.json`. Default: `/.flatbread/artifacts/dag--/`. Override path or suppress with `--no-artifacts`. | +| `--no-artifacts` | `false` | Suppresses per-task transcripts, `_index.md`, and `_dag.json`; does **not** suppress `--findings-dir` JSON sidecars (separate code path). Canvas is still written. | + +### Step 4 — Summarize + +After the runner exits, briefly summarize what completed/failed and re-link the canvas with the exact text `[Open Canvas](file:///Users//.cursor/projects//canvases/dag-.canvas.tsx)` so the user can scroll back to it. Include the absolute fallback path only if useful. + +## Complexity → model + +| Complexity | Model | +| ---------- | ----------------- | +| HIGH | `claude-opus-4-7` | +| MED | `composer-2` | +| LOW | `gpt-5.4-nano` | + +Override any subset inline with top-level DAG `models`, or pass a reusable profile with `--models-file `. Values can be plain SDK model id strings or SDK model selections with `params`. At run time, Proof calls `Cursor.models.list()`, validates ids and param values, and expands partial selections by requiring requested params to match a catalog variant, then choosing the valid variant whose omitted params best match the model's default variant. Precedence is defaults < DAG `models` < `--models-file`. The Cursor model catalog can vary by account. + +To use a cheaper high-capability GPT model, use the base SDK id plus params, not a suffix-style id: + +```json +{ + "models": { + "HIGH": { + "id": "gpt-5.4", + "params": [{ "id": "reasoning", "value": "high" }] + } + } +} +``` + +### Discovering valid model ids + +Many Cursor CLI catalog models encode reasoning effort and Max Mode as **slug suffixes** (e.g. `claude-opus-4-7-thinking-max`, `gpt-5.5-extra-high`, `gpt-5.3-codex-xhigh`), but the Cursor SDK may accept only base slugs plus `params`. Do not compose SDK model ids from CLI suffixes by hand: use `{ "id": "gpt-5.4", "params": [{ "id": "reasoning", "value": "high" }] }`, not `gpt-5.4-high`. For SDK-bound code, prefer `Cursor.models.list()` or the SDK's `ConfigurationError` catalog over `cursor-agent --list-models`. + +Ways to enumerate model ids: + +```bash +# CLI catalog — useful for CLI runs, not authoritative for @cursor/sdk +cursor-agent --list-models + +# SDK-flavored alternative — also prints any per-model `parameters` and preset `variants` +pnpm -F @flatbread/proof models:list # all ids +pnpm -F @flatbread/proof models:list # detail for one model +pnpm -F @flatbread/proof models:list --grep # case-insensitive filter +pnpm -F @flatbread/proof models:list --json +``` + +## Auth + +The runner reads `CURSOR_API_KEY` from the environment. Set it however you usually manage secrets: + +```bash +export CURSOR_API_KEY=crsr_... +``` + +If the current workspace has a `.env` containing it, source that first: + +```bash +set -a && source .env && set +a +``` + +## CLI options + +| Flag | Default | Notes | +| ---------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--dag` | required | Path to the DAG JSON file. | +| `--canvas-path` | composed from below | Full path to the canvas file. Preferred as an absolute path for parent-managed flow; relative paths are accepted and resolve from the runner process cwd, not `--cwd`. | +| `--canvas` | — | Canvas filename stem (no `.canvas.tsx`). Used only if `--canvas-path` is omitted. | +| `--canvases-dir` | derived from cwd | Override the canvases output directory. Used only with `--canvas`. | +| `--cwd` | `process.cwd()` | Working dir each subagent operates in. | +| `--models-file` | — | JSON file containing a partial complexity → model override map. | +| `--debounce` | `200` (ms) | Canvas write debounce interval. | +| `--init-only` | `false` | Write the initial all-`PENDING` canvas and exit. No `CURSOR_API_KEY` required. | +| `--full-output-dir` | computed default | Per-task transcripts as `${taskId}.md` plus `_index.md` and `_dag.json`. Defaults to `/.flatbread/artifacts/dag--/`. Override with an explicit path or suppress with `--no-artifacts`. | +| `--no-artifacts` | `false` | Suppresses per-task transcripts, `_index.md`, and `_dag.json`; does **not** suppress `--findings-dir` JSON sidecars (separate code path). Canvas is still written. | +| `--findings-dir` | — | Per-task JSON sidecars as `${taskId}.findings.json` for original runs and `${taskId}.iter.findings.json` for convergence re-runs. Schema: `{ taskId, iteration, status, durationMs, sections }`. | +| `--state-path` | — | Persist resumable runner state. Defaults to `.proof/run-state.json` when `--restart-on-runner-change` is set. | +| `--resume-state` | — | Load a persisted `RunState` and skip already terminal tasks. | +| `--restart-on-runner-change` | `false` | Detect runner runtime file changes after safe boundaries and exit `75` for supervisor restart. | +| `--max-runner-restarts` | `20` | Supervisor-only cap for relaunches from `proof-supervisor`. | +| `--task-timeout-ms` | `1200000` (20 min) | Marks a task `ERROR` if it exceeds this duration. | +| `--stream-publish-ms` | `500` (ms) | Throttles live canvas streaming writes to avoid excessive cloning. | +| `--stream-idle-timeout-ms` | `300000` (5 min) | Marks a task `ERROR` if no stream events arrive within this window. | + +## Caveats + +- Per-task markdown transcripts, a run index (`_index.md`), and the DAG definition (`_dag.json`) are written under **`/.flatbread/artifacts/`** by default on **full DAG runs** (not `--init-only` or `--dry-check-cmds`). Pass `--no-artifacts` to suppress transcripts/index/DAG JSON, or `--full-output-dir` to override the path. `_index.md` links only transcripts that exist; if an individual transcript write fails, that row is marked as a missing transcript. **`--no-artifacts` does not disable `--findings-dir`** — for fully clean disk output, omit `--findings-dir` as well. In CI or read-only workspaces you may want `--no-artifacts` or a writable `--full-output-dir`. +- When using `proof-supervisor`, each **child runner process** recomputes the default artifacts path with a new timestamp unless you pin a stable directory. The supervisor forwards the full argv to each child (only `--max-runner-restarts` is stripped), so put **`--full-output-dir ` on the supervisor invocation** if every restart should write into the same artifacts folder. +- `--resume-state` creates a new artifact directory for the resumed session; tasks completed in prior sessions do not have transcripts in the new directory. +- Local runtime only — every subagent runs against `--cwd` (defaults to wherever you invoke the runner). +- Sibling tasks in the same rank run in parallel; do not let them write the same files. +- Inline MCP servers and sub-sub-agents are not configured by this runner. +- A failed task automatically skips all downstream dependents (they are marked `ERROR` with a "Skipped: upstream task(s) … failed" message). This prevents wasted API calls on tasks whose inputs are missing. +- Per-task streamed text is capped at `STREAM_CAP = 4000` chars to keep the canvas file modest. Upstream context passed to child tasks is capped at 2000 chars per parent, with section-aware truncation when the parent output contains multiple `##` sections. +- Timed-out tasks are marked `ERROR` instead of staying indefinitely in `RUNNING`. +- SIGINT/SIGTERM/SIGHUP gracefully cancel all in-flight subagents and finalize the canvas before exiting. +- Unexpected unhandled rejections from SDK internals are suppressed to prevent runner crashes; uncaught exceptions are logged and trigger a clean shutdown. + +## Reference + +- Package: `@flatbread/proof` at `packages/proof` +- DAG schema example: `.cursor/skills/proof/examples/example_dag.json` +- Library exports: `import { parseDAG, computeRanks, ... } from '@flatbread/proof'` +- Cursor SDK docs: https://cursor.com/docs/api/sdk/typescript + +```` + +## Source: `.cursor/skills/dag-task-runner/SKILL.md` + +Category: skill + +```md +--- +name: dag-task-runner +description: DEPRECATED ALIAS — the DAG task runner has been promoted to the workspace package @flatbread/proof. Use the `proof` skill (.cursor/skills/proof/SKILL.md) for new work; this entry only exists to redirect agents that still reference the old name. +--- + +# DAG Task Runner — moved to `proof` + +This skill has been renamed and promoted from a copy-into-skill bundle to a first-class Flatbread monorepo package. + +## What changed + +| Before | After | +| -------------------------------------------------------- | -------------------------------------------- | +| Skill name `dag-task-runner` | Skill name `proof` | +| Runtime in `.cursor/skills/dag-task-runner/scripts/*.ts` | Runtime in `packages/proof/src/*.ts` | +| Run via `tsx .cursor/skills/.../run_dag.ts` | Run via `pnpm exec proof` | +| Supervisor `tsx .../run_dag_supervisor.ts` | Supervisor `pnpm exec proof-supervisor` | +| Default state dir `.dag-runner/` | Default state dir `.proof/` | +| Log prefix `[dag-runner]` / `[dag-runner-supervisor]` | Log prefix `[proof]` / `[proof-supervisor]` | +| Examples at `.cursor/skills/dag-task-runner/examples/` | Examples at `.cursor/skills/proof/examples/` | + +CLI flag names, the DAG JSON schema, the `.canvas.tsx` shape, oracle / pause / convergence semantics, and the public library API are all unchanged. Existing DAG JSON files and persisted run-state files (move them from `.dag-runner/` to `.proof/` if you want to resume) work as-is. + +## What to do + +1. Open `.cursor/skills/proof/SKILL.md` for the canonical workflow. +2. Replace any hardcoded `.cursor/skills/dag-task-runner/scripts/run_dag.ts` paths in your prompts / playbooks with the `pnpm exec proof` invocation. +3. If you have an in-flight run with `.dag-runner/run-state.json`, either rename the directory to `.proof/` or pass the old path explicitly via `--state-path`. + +## Why + +`dag-task-runner` was always a copy-into-project bundle, which meant every project carried its own bit-rotted snapshot of the runtime. Promoting it to `@flatbread/proof` lets the runtime evolve in lockstep with the rest of the Flatbread monorepo (tsup builds, lint, type checks) and gives downstream tooling a stable `import { parseDAG, computeRanks, ... } from '@flatbread/proof'` library surface alongside the CLI. + +```` diff --git a/.flatbread/proof/setup/owned-guidelines.manifest.json b/.flatbread/proof/setup/owned-guidelines.manifest.json new file mode 100644 index 00000000..3eba346b --- /dev/null +++ b/.flatbread/proof/setup/owned-guidelines.manifest.json @@ -0,0 +1,56 @@ +{ + "version": 2, + "generatedAt": "2026-05-12T01:54:22.285Z", + "generator": "proof setup", + "bundlePath": ".flatbread/proof/setup/owned-guidelines.bundle.md", + "bundleSha256": "8953c043b164bee9fcacf0bf18a98d7df5bf093ec95037ddb4be2f53ce0f3188", + "missingExpectedGuidelines": [], + "updateDirective": "If Proof-related work changes rules, docs, skills, prompts, or runtime behavior, update the authoritative source files, run `pnpm -F @flatbread/proof build`, and rerun `pnpm exec proof setup` before concluding so the owned-guidelines bundle and manifest do not go stale.", + "sources": [ + { + "id": "proof-usage-guardrails", + "title": "Proof usage guardrails", + "category": "workspace-rule", + "path": ".cursor/rules/proof-usage-guardrails.mdc", + "sha256": "e0c1aca4f65a87de7151f399b79bcf16df7ae83e96c6d098ab98cf57ec3a602d", + "sizeBytes": 1490, + "mtimeMs": 1778550732279.1948 + }, + { + "id": "workspace-agents", + "title": "Workspace agent contract", + "category": "workspace-contract", + "path": "AGENTS.md", + "sha256": "f7b38cb7d9b82ed2384f6c2f4bc5ae6f5cfe35d86712215650326115bdd7b76c", + "sizeBytes": 4227, + "mtimeMs": 1778550712353.7864 + }, + { + "id": "proof-readme", + "title": "@flatbread/proof README", + "category": "package-readme", + "path": "packages/proof/README.md", + "sha256": "fe2a8378e21fe8b3ea32600c5cf9f7f82d095085ce3229efe9bf9a06f858fa38", + "sizeBytes": 9835, + "mtimeMs": 1778550794057.0764 + }, + { + "id": "proof-skill", + "title": "Proof skill guide", + "category": "skill", + "path": ".cursor/skills/proof/SKILL.md", + "sha256": "fb8b12568afc9c42175412af7d5d61d9904fc895eb0cb3268e6e29cf5d3bf43d", + "sizeBytes": 23024, + "mtimeMs": 1778550732255.417 + }, + { + "id": "dag-task-runner-compat-skill", + "title": "Legacy dag-task-runner compatibility skill", + "category": "skill", + "path": ".cursor/skills/dag-task-runner/SKILL.md", + "sha256": "8e3071f34dedf182a8cebd763e84bae5144d99d5758d3edf815ed6051f8baac1", + "sizeBytes": 2539, + "mtimeMs": 1778203951049.3923 + } + ] +} diff --git a/.flatbread/proof/setup/setup-dag.json b/.flatbread/proof/setup/setup-dag.json new file mode 100644 index 00000000..c79fa92b --- /dev/null +++ b/.flatbread/proof/setup/setup-dag.json @@ -0,0 +1,20 @@ +{ + "title": "Proof setup for flatbread", + "framing": "You are working on Proof setup for this repository.\n\nThe derived owned-guidelines bundle is `.flatbread/proof/setup/owned-guidelines.bundle.md` and its manifest is `.flatbread/proof/setup/owned-guidelines.manifest.json`.\n\nIf Proof-related work changes rules, docs, skills, prompts, or runtime behavior, update the authoritative source files, run `pnpm -F @flatbread/proof build`, and rerun `pnpm exec proof setup` before concluding so the owned-guidelines bundle and manifest do not go stale.", + "tasks": [ + { + "id": "inspect-proof-setup-context", + "depends_on": [], + "complexity": "LOW", + "subtask_prompt": "Read `.flatbread/proof/setup/setup-summary.md`, `.flatbread/proof/setup/owned-guidelines.bundle.md`, and `.flatbread/proof/setup/owned-guidelines.manifest.json`.\nSummarize the repo-owned Proof guidance, the current setup status, and the exact gaps that remain.\nTreat the source files summarized in `.flatbread/proof/setup/owned-guidelines.bundle.md` as authoritative for edits; the bundle itself is derived context.\nIf Proof-related work changes rules, docs, skills, prompts, or runtime behavior, update the authoritative source files, run `pnpm -F @flatbread/proof build`, and rerun `pnpm exec proof setup` before concluding so the owned-guidelines bundle and manifest do not go stale.", + "kind": "task" + }, + { + "id": "review-proof-setup", + "depends_on": ["inspect-proof-setup-context"], + "complexity": "HIGH", + "subtask_prompt": "Review the current Proof setup state and any edits made by upstream tasks.\n\nIf setup gaps remain, or if Proof-related work changed behavior without the refresh step re-running `proof setup` and re-ingesting the owned guidelines artifacts, report that under `## Blockers` or `## High-severity findings`.\n\nRe-check `.flatbread/proof/setup/setup-summary.md`, `.flatbread/proof/setup/owned-guidelines.bundle.md`, and `.flatbread/proof/setup/owned-guidelines.manifest.json` before concluding.", + "kind": "task" + } + ] +} diff --git a/.flatbread/proof/setup/setup-summary.md b/.flatbread/proof/setup/setup-summary.md new file mode 100644 index 00000000..f908dc36 --- /dev/null +++ b/.flatbread/proof/setup/setup-summary.md @@ -0,0 +1,25 @@ +# Proof Setup Summary + +- **Owned guidelines bundle:** `.flatbread/proof/setup/owned-guidelines.bundle.md` (regenerated) +- **Owned guidelines manifest:** `.flatbread/proof/setup/owned-guidelines.manifest.json` +- **Generated setup DAG:** `.flatbread/proof/setup/setup-dag.json` +- **Freshness check before this run:** manifest bundle hash does not match bundle content; owned guidelines bundle content does not match current source files; manifest metadata changed for .cursor/rules/proof-usage-guardrails.mdc (mtimeMs); manifest metadata changed for AGENTS.md (mtimeMs); packages/proof/README.md changed since last bundle; manifest metadata changed for packages/proof/README.md (mtimeMs); manifest metadata changed for .cursor/skills/proof/SKILL.md (mtimeMs) + +## Guidance Sources + +- `.cursor/rules/proof-usage-guardrails.mdc` (workspace-rule) +- `AGENTS.md` (workspace-contract) +- `packages/proof/README.md` (package-readme) +- `.cursor/skills/proof/SKILL.md` (skill) +- `.cursor/skills/dag-task-runner/SKILL.md` (skill) + +## Gaps + +- No Proof setup gaps were detected. + +## Maintenance Contract + +- If Proof-related work changes rules, docs, skills, prompts, or runtime behavior, update the authoritative source files, run `pnpm -F @flatbread/proof build`, and rerun `pnpm exec proof setup` before concluding so the owned-guidelines bundle and manifest do not go stale. +- Default `proof setup` only refreshes/reuses the owned bundle, computes gaps, and writes the DAG/summary. +- When setup gaps exist, the generated DAG inserts an explicit `proof setup` refresh step after corrective edits so review reads regenerated owned-guidelines artifacts instead of stale pre-edit files. +- Use `proof setup --run-agents` to hand the generated DAG to the existing Proof runner. diff --git a/AGENTS.md b/AGENTS.md index 387276f3..319f069a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,20 +4,21 @@ ### Overview -Flatbread is a Git-native relational content layer for TypeScript/JavaScript applications. It's a pnpm monorepo that sources flat files (Markdown, YAML), transforms them into relational data, and auto-generates a GraphQL API. See `CONTRIBUTING.md` for full development workflow. +Flatbread is Git-native **relational content for TypeScript/JavaScript apps**: flat files become a typed graph; **GraphQL is one read surface**, not the whole product. It's a pnpm monorepo. See `CONTRIBUTING.md` for the canonical onboarding path. ### Key commands See `CONTRIBUTING.md` for full details. Quick reference: -- **Install**: `pnpm install` -- **Build**: `pnpm build` +- **Install**: `pnpm install` (enforces pnpm via `preinstall` script) +- **Build**: `pnpm build` (builds all packages except examples via tsup) - **Lint**: `pnpm lint` (prettier) - **Lint fix (after edits)**: `pnpm lint:fix:fast` (writes formatting repo-wide to match `pnpm lint`; staged-only: `pnpm lint:fix`, also runs via `.husky/pre-commit`) - **Typecheck**: `pnpm typecheck` -- **Test**: `pnpm test` (builds, then runs ava + vitest suites) +- **Test**: `pnpm test` (builds, then runs ava + vitest suites, including `@flatbread/proof` bounded-loop coverage). For the focused proof loop suite: `pnpm -F @flatbread/proof test`. Vitest packages use `pnpm -F @flatbread/utils exec vitest run` / `pnpm -F @flatbread/codegen exec vitest run` (`run` avoids watch mode). - **Full verify**: `pnpm verify` (lint + typecheck + build + test) -- **Dev server**: `pnpm play` (GraphQL on port 5057, Next.js on port 3000) +- **Proof loop contract**: explicit `DAG.loops[].reexecute.tasks` subsets must be dependency-closed, multiple loops must have disjoint re-execution sets, and `DAG.loops` must not be combined with `--converge-on`. +- **Dev server**: `pnpm play` (GraphQL on port 5057, Next.js on port 3000). From `examples/nextjs`, prefer `pnpm exec flatbread start -- next dev --turbopack`. Use `flatbread start` — `flatbread dev` is not a CLI command. ### Mergify Stacks @@ -32,9 +33,9 @@ The repo uses Mergify stacks for PR management. The `mergify-cli` is installed v - **`@flatbread/proof` requires `CURSOR_RIPGREP_PATH`.** The proof package uses `@cursor/sdk` which expects a bundled ripgrep. In Cloud Agent VMs, set `export CURSOR_RIPGREP_PATH=/usr/bin/rg` to use the system ripgrep (included in the update script). - **Native build scripts are approved in `pnpm-workspace.yaml`.** The `onlyBuiltDependencies` list allows esbuild, sharp, @swc/core, etc. to run their postinstall scripts automatically during `pnpm install`. - **Vitest packages run in watch mode by default.** Always use `vitest run` (not bare `vitest`) to get a single run and exit. -- **`flatbread` CLI is not on PATH.** Use `npx flatbread` when running from a shell. The `pnpm play` script from the root handles this automatically. +- **`flatbread` CLI is not on PATH globally.** From `examples/nextjs`, prefer `pnpm exec flatbread …` (local binary), or `npx flatbread` from a shell. The `pnpm play` script from the root handles this automatically. - **Build before test.** All packages must be built (`pnpm build`) before running tests or starting dev servers. `pnpm test` handles this automatically. -- **The Next.js example `dev` script uses `--https`.** This requires an SSL certificate. In headless/CI environments, run without `--https`: `npx flatbread start -- next dev --turbopack`. +- **The Next.js example `dev` script uses `--https`.** This requires an SSL certificate. In headless/CI environments, run without `--https`: `pnpm exec flatbread start -- next dev --turbopack`. - **Full local CI parity check:** `pnpm verify` runs lint, typecheck, build, and all tests. ### Weave merge driver diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8a8e030..43604fc4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,18 +2,35 @@ Thanks for your interest in contributing! This guide covers local development and the release process (bumping versions and publishing packages). +**Flatbread** is **relational, Git-tracked content for TypeScript apps**: flat files in the repo become a typed content graph. **GraphQL is one consumer** of that graph (see `docs/glossary.md`), not the whole product story. + +For the **canonical posts / authors / tags** onboarding narrative (collections, `refs`, codegen, then GraphQL), see the [Flatbread package README quickstart](https://github.com/FlatbreadLabs/flatbread/blob/main/packages/flatbread/README.md#quickstart-posts-authors-and-tags) (traceability: **files → config → query interface**, tied to **`docs/glossary.md`**). + ## Prerequisites - Node 20.19+ - pnpm 10.33.x via Corepack (`corepack enable && corepack prepare pnpm@10.33.0 --activate`) - Clean git working tree (commit/stash your work first) +## Recommended onboarding (try Flatbread in the Next.js example) + +Use this single path first; it matches how CI and most contributors exercise the stack (**shared content** under `examples/content`, symlinked from the Next app as `content/`): + +1. From the **monorepo root**: `pnpm install` then `pnpm build` (builds all packages except `examples/*`). +2. `cd examples/nextjs` +3. One-shot codegen: `pnpm exec flatbread codegen --verbose` (output: `generated/graphql.ts`; globs and dirs come from `flatbread.config.js`). +4. Run the app **and** Flatbread together with **`flatbread start`** (there is **no** `flatbread dev` subcommand): + - **`pnpm dev`** — Next dev with local HTTPS + Flatbread (GraphQL on **5057**, Next on **3000**). + - Headless / no HTTPS: `pnpm exec flatbread start -- next dev --turbopack`. + +Optional **`pnpm play`** from the repo root is a shortcut for **`cd examples/nextjs && pnpm dev`** — same as step 4 above, not a separate product command. + ## Local development -- Install dependencies: `pnpm -w i` +- Install dependencies: `pnpm install` (or `pnpm -w i`) - Build all packages: `pnpm build` -- Run dev across packages: `pnpm dev` -- Work in examples (Next.js preferred): `pnpm play` +- **Workspace libraries (watch-only):** `pnpm dev` — runs package `dev` scripts (e.g. `tsup --watch`) for `packages/*`; it does **not** start the Next.js example. +- **Next.js example:** prefer the flow under [Recommended onboarding](#recommended-onboarding-try-flatbread-in-the-nextjs-example); or `pnpm play` as a convenience alias. - Check local CI parity before opening a PR: `pnpm verify` ## Working on a package @@ -23,7 +40,7 @@ Open another terminal tab while keeping the dev server running. - Option 1 (preferred): use the Next.js example as a demo project - Work in the full context of a Flatbread instance as an end-user would, while tinkering with `packages/*` internals. - - Command: `pnpm play` (starts the Next.js example) + - Commands: follow [Recommended onboarding](#recommended-onboarding-try-flatbread-in-the-nextjs-example), or from root run **`pnpm play`** (`cd examples/nextjs && pnpm dev`). - Good when you want to test without creating per-package temporary clutter. - Option 2: scope to a specific package @@ -49,12 +66,15 @@ pnpm build - Negative: invalid inputs, edge cases, and error handling/failure modes. - Place tests in the relevant package and use its existing runner/config. - Root `pnpm test` builds the workspace, runs the AVA suite configured by `ava.config.js`, then runs the package-local Vitest suites. + - Bounded-loop coverage for `@flatbread/proof` is exercised by both `pnpm test` and `pnpm -F @flatbread/proof test`. + - That focused proof suite is the quickest check for loop parser/runtime guards such as explicit rerun validation, overlapping-loop rejection, and convergence iteration accounting. - Vitest is currently used by `@flatbread/codegen` and `@flatbread/utils`. - - Most other packages are covered by the root AVA suite or do not yet expose a package-local `test` script. + - `@flatbread/proof` exposes a package-local AVA entrypoint for the loop schema suite; most other packages are covered by the root AVA suite or do not yet expose a package-local `test` script. - `pnpm lint` is the enforced Prettier formatting gate. After editing, run `pnpm lint:fix:fast` so formatting matches CI (Cursor agents: see `.cursor/rules/post-edit-lint-fix.mdc`). On commit, `.husky/pre-commit` runs `pnpm lint:fix` (Pretty Quick on staged files). `pnpm lint:eslint` is an optional/manual root ESLint check until the linting stack is modernized. - Helpful commands: - Local CI parity: `pnpm verify` - Root test suite: `pnpm test` + - Proof bounded-loop suite: `pnpm -F @flatbread/proof test` - Package-local test scripts where present: `pnpm -r --if-present test` - Single package: `pnpm -F test` - Watch (where supported): `pnpm -F test:watch` diff --git a/ava.config.js b/ava.config.js index 621bd6a6..6d983d59 100644 --- a/ava.config.js +++ b/ava.config.js @@ -1,8 +1,16 @@ export default { + // GraphQL schema generation currently uses graphql-compose's process-global + // schemaComposer. Run AVA files serially so schema-building tests do not + // mutate that shared composer concurrently. + concurrency: 1, files: [ 'packages/**/*.test.(j|t)s', - // Exclude Vitest suites located under __tests__ so AVA doesn't try to run them - '!packages/**/src/__tests__/**', + // Codegen + utils use Vitest under src/__tests__. Keep those out of the + // root AVA run, but allow AVA-owned proof coverage under the same folder + // layout so `pnpm test` exercises the proof bounded-loop suite and its + // parser/runtime guardrails. + '!packages/codegen/src/__tests__/**', + '!packages/utils/src/__tests__/**', ], extensions: { js: true, diff --git a/docs/data-ownership.md b/docs/data-ownership.md new file mode 100644 index 00000000..28ceb243 --- /dev/null +++ b/docs/data-ownership.md @@ -0,0 +1,90 @@ +# Data ownership and exit story + +Flatbread's portability story starts with a simple constraint: **your flat files +remain the source of truth**. Markdown, YAML, and any other source files live in +your repository, move through normal Git workflows, and can be reviewed without +a hosted dashboard. + +## What you own + +- **Raw content files** — posts, authors, tags, and other records are ordinary + repo files. +- **Git history** — every content change can be branched, reviewed, reverted, + and diffed with the same tools as code. +- **Flatbread config** — collection paths, refs, sources, transformers, and + codegen options are explicit project files. +- **Generated artifacts** — GraphQL schema/types, generated read helpers, JSON + snapshots, and CSV flat views can be regenerated from the repo. Generated + read helpers are Flatbread runtime helpers; operation types and snapshots are + the more portable exit artifacts. + +## Exit paths + +| Surface | What it gives you | Exit use | +| --------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| Raw files | Original Markdown/YAML content and frontmatter | Move to another static/content pipeline without export first | +| Git history | Reviewable content lineage | Audit, revert, or migrate by commit range | +| JSON snapshots | Stable collection records with normalized IDs/refs | Feed another app, script, archive, or migration | +| CSV flat views | Spreadsheet-friendly scalar fields and reference IDs; nested objects are omitted | Review simple collections, hand off to non-developers, seed tabular tools | +| GraphQL introspection | The generated read schema | Discover API shape or generate external clients while Flatbread serves the graph | +| Generated TypeScript | Operation types and model helper types | Preserve typed query/result contracts while changing framework integration | + +## JSON and CSV exports + +`@flatbread/core` currently exposes export APIs: + +```ts +import { + exportCollectionsAsCsv, + exportCollectionsAsJson, +} from '@flatbread/core'; +import { loadConfig } from '@flatbread/config'; + +const configResult = await loadConfig({ cwd: process.cwd() }); + +const json = await exportCollectionsAsJson(configResult, { + collections: ['Post', 'Author'], +}); + +const csv = await exportCollectionsAsCsv(configResult, { + collections: ['Post'], +}); +``` + +Both exports validate the content graph before returning output. Broken refs or +duplicate IDs fail before snapshots are produced, which keeps the export story +aligned with Flatbread's relational integrity work. + +See [snapshot export docs](./json-export.md) for sort order, path behavior, +relation handling, and CSV flattening details. + +## GraphQL schema and generated types + +GraphQL is one read interface over the same repo-backed model. While a +Flatbread server is running, standard GraphQL tooling can introspect +`http://localhost:5057/graphql` to discover the generated schema. The checked-in +GraphQL documents and generated TypeScript operation types are useful migration +artifacts because they show the read shapes your app depended on. + +If you leave Flatbread, the prototype generated read API should be treated as a +convenience wrapper to replace or reimplement; the raw files, JSON/CSV +snapshots, GraphQL operation documents, and operation result types are the more +durable exit surfaces. + +## What Flatbread does not lock in + +- You do not need a hosted CMS account to read your source data. +- You do not need a proprietary database dump to recover content. +- You do not need GraphQL to preserve the content itself; GraphQL is one read + interface over the repo-backed model. +- You can keep raw files and migrate to another parser, static pipeline, or + database import script if Flatbread stops fitting the project. + +## Current limitations + +- JSON/CSV export is currently an API surface, not a first-class CLI command. +- CSV is a flat view: nested object fields are omitted, and relation fields are + exported as reference IDs rather than expanded records. +- Generated TypeScript read helpers execute through the GraphQL layer today. +- Live content reload for the long-running `flatbread start` server remains a + separate watch-loop effort; see [local dev loop boundaries](./local-dev-loop.md). diff --git a/docs/edit-file-see-query-update-demo.md b/docs/edit-file-see-query-update-demo.md new file mode 100644 index 00000000..3679d604 --- /dev/null +++ b/docs/edit-file-see-query-update-demo.md @@ -0,0 +1,87 @@ +# Edit file → see query update demo + +This is a single-process demo harness, not the long-running `flatbread start` +server. Production live-editing still requires the unified watch design +described in [local-dev-loop.md](./local-dev-loop.md). + +This demo is the current reproducible path for issue #158. It proves the core +edit/query loop for the canonical **posts → authors + tags** model without +requiring a manual process restart in this focused demo path. + +The full `flatbread start` GraphQL server still builds its schema at startup +(see [local dev loop boundaries](./local-dev-loop.md)). This demo therefore +uses a tiny watcher script that rebuilds the Flatbread schema per file event +and executes the same posts/authors/tags query shape the generated TypeScript +read API uses in the Next.js example. + +## Run it from a clean checkout + +```bash +pnpm install +pnpm build +cd examples/nextjs +pnpm exec flatbread codegen --verbose +pnpm run demo:watch-query +``` + +The script watches both Markdown and YAML relation data: + +```text +examples/content/markdown/posts/example-post.md +examples/content/yaml/authors/dr-caffeine.yml +``` + +It prints JSON like: + +```json +{ + "data": { + "allPosts": [ + { + "id": "sdfsdf-23423-sdfsd-23444-dfghf", + "title": "The Art of Measuring Cats in Fruit Units", + "tags": ["cats", "measurements", "fruit-science", "important-research"], + "authors": [ + { "id": "40s3", "name": "Eva" }, + { "id": "2a3e", "name": "Tony" } + ] + } + ], + "allYamlAuthors": [ + { + "id": "caffeine-researcher", + "name": "Dr. Maya Espresso", + "friend": { "id": "2a3e", "name": "Tony" } + } + ] + } +} +``` + +## Try the edit + +In another terminal, edit the watched Markdown post title plus a YAML author +name and `friend` relation: + +```bash +pnpm --filter nextjs run demo:edit +``` + +The watcher prints fresh query results with the edited Markdown title, edited +YAML author name, and changed YAML `friend` relation. Restore the files after +the demo: + +```bash +pnpm --filter nextjs run demo:restore +``` + +## What this does and does not prove + +- ✅ Editing relation-backed Markdown and YAML updates the query result without + a manual restart in this demo path. +- ✅ The query includes post fields, tag facets, resolved Markdown author + records, and resolved YAML author records. +- ✅ The demo is reproducible from the monorepo root with pnpm commands. +- ⚠️ The long-running `flatbread start` server still needs restart for content + and schema changes today. +- ⚠️ The watcher script is a demo harness, not the final `flatbread start --watch` implementation described in [local-dev-loop.md](./local-dev-loop.md). diff --git a/docs/experiments/fixtures/cursor-proof-skill-effort-graph/README.md b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/README.md new file mode 100644 index 00000000..78209c91 --- /dev/null +++ b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/README.md @@ -0,0 +1,12 @@ +# Fixture: Cursor `proof` skill → Effort Graph rows + +**Purpose:** Representative markdown files showing how **existing** agent harness paths under [`.cursor/skills/proof/`](../../../../.cursor/skills/proof/) map to **Effort Graph** collections without moving or rewriting the harness. + +| File here | Collection | Maps from | +| -------------------------------------------- | ---------- | ----------------------------------------------------------------- | +| `efforts/pmf-audit-dag.md` | Effort | Logical thread for the PMF audit DAG work | +| `plans/flatbread-flow-pmf-audit-dag.md` | Plan | Title + provenance ↔ `examples/dag-flatbread-flow-pmf-audit.json` | +| `sessions/proof-cli-session-20260508.md` | Session | Synthetic “one run” of the proof skill / CLI | +| `decisions/167-blocking-reference-layout.md` | Decision | Issue #167 acceptance: layout indexed + queryable context | + +Use with the config excerpt in [issue-167-effort-graph-layout-mapping.md](../../issue-167-effort-graph-layout-mapping.md). diff --git a/docs/experiments/fixtures/cursor-proof-skill-effort-graph/decisions/167-blocking-reference-layout.md b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/decisions/167-blocking-reference-layout.md new file mode 100644 index 00000000..7b8b41b8 --- /dev/null +++ b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/decisions/167-blocking-reference-layout.md @@ -0,0 +1,17 @@ +--- +id: decision-167-reference-layout +effort: pmf-audit-dag +plan: plan-pmf-audit-dag +session: session-proof-20260508 +title: 'Issue #167 — Reference Effort Graph layout must index agent proof artifacts' +status: open +blocking: true +decided_at: null +tool: cursor-proof +--- + +# Decision + +**Acceptance (issue #167):** One **real or representative** agent artifact layout is mapped to Effort Graph–style collections; a **single** query surface can return **blocking** decisions for the current effort with **plan** and **session** context in one response. + +This row models an **open, blocking** gate: shipping broader Effort Graph marketing before **ID normalization**, **ref validation**, and **watch** is explicitly discouraged in the PMF audit and artifact opportunity docs; confirm scope on GitHub #167 vs full Session/Run automation. diff --git a/docs/experiments/fixtures/cursor-proof-skill-effort-graph/efforts/pmf-audit-dag.md b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/efforts/pmf-audit-dag.md new file mode 100644 index 00000000..d853b24b --- /dev/null +++ b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/efforts/pmf-audit-dag.md @@ -0,0 +1,13 @@ +--- +id: pmf-audit-dag +status: active +canonical_branch: cursor/docs-positioning-non-goals-18a9 +external_issue: '167' +focus: 'Flatbread Flow PMF audit — DAG-shaped planner output' +--- + +# Effort: PMF audit DAG (proof skill) + +This effort groups agentic work where the **proof** Cursor skill authors a JSON DAG (see `.cursor/skills/proof/examples/dag-flatbread-flow-pmf-audit.json`) and executes it with local subagents. + +The **Effort** row is the stable anchor forPlans, Sessions, and Decisions that must be queryable as a graph. diff --git a/docs/experiments/fixtures/cursor-proof-skill-effort-graph/plans/flatbread-flow-pmf-audit-dag.md b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/plans/flatbread-flow-pmf-audit-dag.md new file mode 100644 index 00000000..9fed4cfb --- /dev/null +++ b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/plans/flatbread-flow-pmf-audit-dag.md @@ -0,0 +1,13 @@ +--- +id: plan-pmf-audit-dag +effort: pmf-audit-dag +title: 'Flatbread Flow PMF Audit (no sub-sub-agents)' +source_artifact: .cursor/skills/proof/examples/dag-flatbread-flow-pmf-audit.json +framing: 'Treat Flatbread as Git-native relational content for TypeScript apps, backed by flat files. GraphQL is one interface, not the whole product identity.' +--- + +# Plan body (derived from DAG) + +The canonical DAG spec lives at `source_artifact`. This markdown row exists so Flatbread can index **title**, **effort** ref, and narrative in **one** `Plan` collection while the JSON remains the machine-native task graph. + +Top-level tasks include `map-current-flow`, `relational-content-needs`, `recommend-roadmap`, and merge nodes—suitable for PMF positioning and architecture audits. diff --git a/docs/experiments/fixtures/cursor-proof-skill-effort-graph/sessions/proof-cli-session-20260508.md b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/sessions/proof-cli-session-20260508.md new file mode 100644 index 00000000..f92f57b4 --- /dev/null +++ b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/sessions/proof-cli-session-20260508.md @@ -0,0 +1,12 @@ +--- +id: session-proof-20260508 +effort: pmf-audit-dag +runner: proof-cli +canvas_pattern: '.canvas.tsx (hot-recompiled DAG status)' +--- + +# Session: proof harness run (representative) + +Synthetic but representative **Session** for a single proof DAG execution: parent agent loads `dag-flatbread-flow-pmf-audit.json`, streams subagent status into the canvas, and persists human-facing summaries elsewhere. + +Full **Run**-level fidelity (append-only tool traces, per-task tokens) is **out of scope** for this fixture; see [flatbread-agent-artifact-opportunity.md §10](../../../../../flatbread-agent-artifact-opportunity.md). diff --git a/docs/experiments/fixtures/issue-168-three-layout-snippets/README.md b/docs/experiments/fixtures/issue-168-three-layout-snippets/README.md new file mode 100644 index 00000000..5d9415f4 --- /dev/null +++ b/docs/experiments/fixtures/issue-168-three-layout-snippets/README.md @@ -0,0 +1,11 @@ +# Fixtures: Issue #168 — Three harness layout snippets + +**Purpose:** Small, **non-authoritative** excerpts illustrating **L1** Claude-oriented skills, **L2** Cursor rules + skill/DAG, and **L3** synthetic GCC-style branch context. They support the adversarial schema report [issue-168-adversarial-multi-layout-schema.md](../../issue-168-adversarial-multi-layout-schema.md). + +| Path | Layout | +| ---------------------------------------------------------------------------------------- | ------------------------------------------------ | +| [`layout-claude-code/skill-stub-excerpt.md`](./layout-claude-code/skill-stub-excerpt.md) | Claude Code–style skill stub (YAML + body) | +| [`layout-cursor/rules-and-dag-excerpt.md`](./layout-cursor/rules-and-dag-excerpt.md) | Cursor `.mdc` rule + DAG JSON excerpt | +| [`layout-gcc/representative-tree.md`](./layout-gcc/representative-tree.md) | Synthetic `.GCC/` tree description + example row | + +**Acceptance test contract:** [`acceptance-test-matrix.md`](./acceptance-test-matrix.md) diff --git a/docs/experiments/fixtures/issue-168-three-layout-snippets/acceptance-test-matrix.md b/docs/experiments/fixtures/issue-168-three-layout-snippets/acceptance-test-matrix.md new file mode 100644 index 00000000..59f93442 --- /dev/null +++ b/docs/experiments/fixtures/issue-168-three-layout-snippets/acceptance-test-matrix.md @@ -0,0 +1,19 @@ +# Acceptance test matrix — Issue #168 (three harness layouts) + +**Intent:** Executable **markdown contract** for the adversarial schema experiment: each layout row must map to the **same** canonical collections (`Effort`, `Plan`, `Session`, `Decision`) without changing collection names. + +| TC | Layout | Harness source (fixture or repo path) | Prove (design / review) | +| -------- | ------------------------------ | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **TC-1** | **L1 — Claude-oriented** | [`layout-claude-code/skill-stub-excerpt.md`](./layout-claude-code/skill-stub-excerpt.md) | At least one **`Plan`** or **`Artifact`** mapping rule is defined for skill-style markdown; **`Effort.id`** is chosen independently of frontmatter `name` if they differ | +| **TC-2** | **L2 — Cursor rules + skills** | [`layout-cursor/rules-and-dag-excerpt.md`](./layout-cursor/rules-and-dag-excerpt.md) | **Split sources**: `.mdc` maps to **`Artifact`** (or explicit exclude) **and** DAG JSON maps to **`Plan.source_artifact`**; refs remain valid across both | +| **TC-3** | **L3 — GCC branch tree** | [`layout-gcc/representative-tree.md`](./layout-gcc/representative-tree.md) | Profile documents **branch-scoped paths** → canonical repo paths; **identity risk** (`Effort.external_branch`) is enumerated; merge behavior marked **policy**, not automatic | + +## Field stability assertions (must hold for all TCs) + +- **Stable after ingest:** `Effort.id`, `Decision.blocking`, `refs` targets (`effort`, `plan`, `session`). +- **Layout-specific mapping:** paths in `Plan.source_artifact`, session runner labels, inclusion of rule files as graph rows. + +## Pass / fail bar + +- **Pass:** Report [issue-168](../../issue-168-adversarial-multi-layout-schema.md) documents per-TC outcomes and concludes on **single schema + mapping layer** viability. +- **Fail:** Any TC requires **renaming collections** or **forking schemas** without a bridging profile — escalate to roadmap. diff --git a/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-claude-code/skill-stub-excerpt.md b/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-claude-code/skill-stub-excerpt.md new file mode 100644 index 00000000..80e87e98 --- /dev/null +++ b/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-claude-code/skill-stub-excerpt.md @@ -0,0 +1,16 @@ +## + +name: example-claude-skill +description: Example skill for layout stress — not installed product documentation. +allowed-tools: Bash(example:\*) +hidden: false + +--- + +# example-claude-skill + +Body is narrative; there is **no** companion JSON DAG in this excerpt. Mapping options for Flatbread: + +- **Plan.title** ← first `#` heading or frontmatter `name`. +- **Plan.source_artifact** ← path to this `SKILL.md`. +- **Effort** row may set `external_issue: "168"` for traceability while `id` remains a team-chosen slug. diff --git a/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-cursor/rules-and-dag-excerpt.md b/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-cursor/rules-and-dag-excerpt.md new file mode 100644 index 00000000..58aa54c6 --- /dev/null +++ b/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-cursor/rules-and-dag-excerpt.md @@ -0,0 +1,24 @@ +# Representative L2: Cursor rule frontmatter + DAG JSON shape (excerpt) + +## Rule file (`.cursor/rules/*.mdc` pattern) + +```yaml +--- +description: typescript, .tsx +alwaysApply: false +--- +# TypeScript Best Practices +``` + +**Mapping note:** `alwaysApply` + path are **tool-specific** metadata; if indexed, use **`Artifact`** with `kind: cursor-rule` (conceptual) or exclude from graph per team policy. + +## DAG JSON (proof skill example — truncated) + +```json +{ + "title": "Flatbread flow — PMF audit DAG", + "tasks": [{ "id": "t1", "subtask_prompt": "Plan the audit scope." }] +} +``` + +**Mapping note:** **`Plan.title`** and **`Plan.source_artifact`** point here; **`Effort`** slug is **not** implied by the JSON filename — declare explicitly on the Effort row (see [#167](../../cursor-proof-skill-effort-graph/) fixtures). diff --git a/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-gcc/representative-tree.md b/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-gcc/representative-tree.md new file mode 100644 index 00000000..0d109e72 --- /dev/null +++ b/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-gcc/representative-tree.md @@ -0,0 +1,32 @@ +# Representative L3: GCC-style `.GCC/` tree (synthetic) + +**Note:** This repository does not ship a live `.GCC/` directory; this file describes the **expected shape** for adversarial mapping (per [Git Context Controller](https://arxiv.org/html/2508.00031v2) style branch knowledge). + +## Example tree (conceptual) + +```text +.GCC/ + branches/ + feature-pmf-audit/ + CONTEXT.md + DECISIONS.yaml + sessions/ + 2026-05-08-run.md +``` + +## Example `DECISIONS.yaml` fragment + +```yaml +decisions: + - id: gcc-decision-001 + blocking: true + status: open + effort_slug: pmf-audit-dag + relates_to_plan: ../CONTEXT.md +``` + +## Mapping stressors + +- **`Effort.external_branch`:** `feature-pmf-audit` vs canonical `Effort.id: pmf-audit-dag`. +- **Path relativity:** ingest must normalize branch-relative paths to repo anchors for `source_artifact`. +- **Merge:** closing or linking efforts when a branch merges is a **human / team policy** — core should not assume automatic row merges. diff --git a/docs/experiments/issue-162-relational-starter-benchmark.md b/docs/experiments/issue-162-relational-starter-benchmark.md new file mode 100644 index 00000000..0f6558c1 --- /dev/null +++ b/docs/experiments/issue-162-relational-starter-benchmark.md @@ -0,0 +1,223 @@ +# Experiment: Issue #162 — relational starter benchmark + +## Question + +Can a developer start from Flatbread's canonical existing example, understand +the `posts → authors + tags` model, and reach a typed read result in under 10 +minutes? + +## Benchmark path + +This uses the repo's canonical onboarding route on the existing example: + +1. Read the root README quickstart: + [`packages/flatbread/README.md#quickstart-posts-authors-and-tags`](../../packages/flatbread/README.md#quickstart-posts-authors-and-tags). +2. Inspect the backing files: + - `examples/content/markdown/posts/example-post.md` + - `examples/content/markdown/authors/tony.md` + - `examples/content/markdown/authors/eva.md` +3. Inspect the relation config: + `examples/nextjs/flatbread.config.js` +4. Generate typed artifacts from the example directory: + `cd examples/nextjs && pnpm exec flatbread codegen --clear-cache --verbose` +5. Confirm the typed read surface: + - GraphQL operation types in `examples/nextjs/generated/graphql.ts` + - generated read API usage in `examples/nextjs/lib/read.ts` +6. Verify the generated TypeScript read API path: + `pnpm --filter nextjs build` +7. Optional raw query watch proof: + `pnpm --filter nextjs run demo:watch-query` + +## Fresh-worktree run + +Environment: detached Git worktree created from the current branch with empty +workspace `node_modules` (pnpm reused the global package store). + +Command: + +```bash +git worktree add --detach /tmp/flatbread-starter-benchmark-worktree HEAD +cd /tmp/flatbread-starter-benchmark-worktree +start=$(date +%s) +corepack enable +pnpm install +pnpm build +cd examples/nextjs +pnpm exec flatbread codegen --clear-cache --verbose +timeout 8s pnpm run demo:watch-query +end=$(date +%s) +echo "elapsed_seconds=$((end-start))" +``` + +Observed result: + +```text +elapsed_seconds=49 +Done in 27.2s using pnpm v10.33.0 +✓ Generated TypeScript types: /tmp/flatbread-starter-benchmark-worktree/examples/nextjs/generated/graphql.ts +``` + +Relevant first-query output before the watcher timeout: + +```json +{ + "data": { + "allPosts": [ + { + "id": "sdfsdf-23423-sdfsd-23444-dfghf", + "title": "The Art of Measuring Cats in Fruit Units", + "tags": ["cats", "measurements", "fruit-science", "important-research"], + "authors": [ + { "id": "40s3", "name": "Eva" }, + { "id": "2a3e", "name": "Tony" } + ] + } + ], + "allYamlAuthors": [ + { + "id": "caffeine-researcher", + "name": "Dr. Maya Espresso", + "friend": { "id": "2a3e", "name": "Tony" } + } + ] + } +} +``` + +The watcher is intentionally long-running, so the shell command used +`timeout 8s`; the first render completed before timeout and no GraphQL `errors` +field was present. + +This completes the canonical install → build → codegen → first demo query path +on the existing example in under 10 minutes. + +## Generated TypeScript read API verification + +The Next.js home page imports `getPostsAuthorsAndTagsViaReadApi()` and +`getAuthorsViaReadApi()` from `examples/nextjs/lib/read.ts`. Static generation +therefore exercises the generated TypeScript read API path. + +Command: + +```bash +start=$(date +%s) +pnpm --filter nextjs build +end=$(date +%s) +echo "elapsed_seconds=$((end-start))" +``` + +Observed result: + +```text +elapsed_seconds=18 +✓ Compiled successfully in 3.0s +✓ Generating static pages (5/5) +Flatbread is done for now. Bye bye! 🥪 +``` + +The build still emits the known `eslint-plugin-react-hooks` warning, but exits +0 and renders the page path that calls the generated read API. + +## Warm-workspace rehearsal + +Environment: existing cloud workspace with dependencies already present. This +is a **canonical-command rehearsal**, not a true empty-cache/fresh-clone +measurement. + +Command: + +```bash +start=$(date +%s) +pnpm install +pnpm build +cd examples/nextjs +pnpm exec flatbread codegen --clear-cache --verbose +timeout 8s pnpm run demo:watch-query +end=$(date +%s) +echo "elapsed_seconds=$((end-start))" +``` + +Observed result: + +```text +elapsed_seconds=23 +Done in 1.9s using pnpm v10.33.0 +✓ Generated TypeScript types: /workspace/examples/nextjs/generated/graphql.ts +✓ TypeScript types generated successfully +"title": "The Art of Measuring Cats in Fruit Units" +"name": "Dr. Maya Espresso" +``` + +This is well under 10 minutes for the canonical command rehearsal in this +workspace. The fresh-worktree run above is the primary timing evidence; this +warm run remains useful for comparing maintainer-loop overhead. + +The docs now place the relation model before GraphQL, so the cognitive steps +are: + +- `Post` files carry `authors` IDs and `tags` string facets. +- `Author` files carry matching IDs. +- `flatbread.config.js` declares `refs: { authors: 'Author' }`. +- Codegen emits GraphQL operation types and Flatbread content-model/read helper + types. + +## Friction observed + +- The fresh-worktree benchmark reused the global pnpm store, so it is not a + network-cold install. +- `pnpm --filter nextjs build` succeeds and exercises the generated read API + path, but still prints a known + `eslint-plugin-react-hooks` load warning from the Next.js ESLint stack. +- `flatbread codegen --watch` is watch-only; docs must keep steering one-shot + benchmark users to `flatbread codegen --verbose`. +- The generated TypeScript read API is still a prototype and executes through + GraphQL, so the current first typed read result is strongest when described + as "GraphQL operations plus generated read helpers over one content model." + +## Follow-up issue drafts + +### Follow-up: Network-cold benchmark on a fresh clone/container + +**Problem:** This benchmark used a fresh worktree but reused the global pnpm +store. + +**Acceptance criteria:** + +- Run from a fresh clone/container with empty `node_modules` and cold pnpm + store/cache. +- Record install, build, codegen, and first query time separately. +- Note native dependency install warnings and remediation steps. + +### Follow-up: Turn friction notes into tracked issues + +**Problem:** This report can draft follow-up work, but the current automation +cannot create/close GitHub issues. + +**Acceptance criteria:** + +- Create project notes or issues for the cold-start benchmark and Next.js ESLint + warning. +- Link those issue URLs back into this report. + +This report is the current project note until GitHub-side follow-ups can be +created by a maintainer. + +### Follow-up: Clean Next.js ESLint dependency warning + +**Problem:** `pnpm --filter nextjs build` exits 0 but reports a missing +`eslint-plugin-react-hooks` plugin. + +**Acceptance criteria:** + +- Add or reconcile the missing plugin dependency. +- `pnpm --filter nextjs build` runs without the plugin warning. + +## Decision + +**Iterate / keep.** The canonical starter path on the existing example now +makes the relation-first value legible and reaches typed output, generated +TypeScript read API execution, and a demo query result comfortably under the +10-minute target in a fresh worktree / warm-store environment. This should be +described as time-to-first-query on the canonical example, not time-to-model +from zero. A stricter network-cold benchmark should still be run before using +the timing as external marketing evidence. diff --git a/docs/experiments/issue-163-typescript-safety-test.md b/docs/experiments/issue-163-typescript-safety-test.md new file mode 100644 index 00000000..1c971989 --- /dev/null +++ b/docs/experiments/issue-163-typescript-safety-test.md @@ -0,0 +1,142 @@ +# Experiment: Issue #163 — TypeScript safety interview/test + +## Question + +Do generated Flatbread types and the prototype TypeScript read API make +posts/authors/tags consumption materially safer than untyped flat-file reads or +hand-written GraphQL strings? + +## Test surface + +Representative files: + +- `examples/nextjs/generated/graphql.ts` +- `examples/nextjs/lib/read.ts` +- `packages/codegen/src/__tests__/e2e.test.ts` +- `packages/core/src/types.test.ts` + +## What works + +- `FlatbreadCollectionName` narrows collection names to configured literals. +- `FlatbreadRecord<'Post'>` ties app code to generated record shape. +- `FlatbreadRelationTarget<'Post', 'authors'>` ties relation traversal to the + configured `refs` target and cardinality. +- `tags` on `Post` remains a string facet (`Post['tags']`), not a relation + helper, because the canonical example does not model `Tag` as a collection. +- `FlatbreadRelationCardinality<'Post', 'authors'>` exposes whether a relation + is one or many. +- `createFlatbreadReadApi()` lets the app read `Post` and `Author` through a + generated collection-shaped API while GraphQL remains the underlying + execution layer. +- Core content/plugin types now use `unknown`, typed `ContentEntry.refs`, and + typed `Source.fetch` inputs instead of broad `any` surfaces. + +## Type-safety test run + +Commands: + +```bash +pnpm --filter @flatbread/codegen build +pnpm -F @flatbread/codegen exec vitest run +pnpm --filter @flatbread/core build +pnpm test:ava -- --match='*content types*' +pnpm --filter nextjs build +``` + +Observed results in this workspace: + +```text +@flatbread/codegen build: passed +@flatbread/codegen vitest: 39 tests passed +@flatbread/core build: passed +AVA content type assertions: passed (the command currently runs the broader AVA suite) +Next.js build: passed, with known eslint-plugin-react-hooks warning +``` + +## Inference gaps and confusing names + +- `createFlatbreadReadApi()` still accepts an optional GraphQL selection string + for advanced use. That selection is not type-checked, so the safest path is + the generated default selection. +- `FlatbreadReadApi` returns `Partial>` because the selected + fields are a runtime concern. This is honest, but less precise than a typed + selection builder would be. +- Generated relation helper names are verbose: + `FlatbreadRelationTargetCollection` versus `FlatbreadRelationTarget` can be + confusing without examples. +- Nullable GraphQL results and generated helper types are not yet perfectly + aligned. The prototype errs toward safe optional/partial reads. +- Flatbread metadata fields such as `_path` and `_slug` are still emitted as + nullable by GraphQL Code Generator even when Flatbread-managed records usually + provide them. +- Core plugin author types are narrower, but `ContentEntry` still permits + arbitrary extra keys for plugin/config extensibility. + +## Verification transcript + +```text +pnpm --filter @flatbread/codegen build +exit 0 + +pnpm -F @flatbread/codegen exec vitest run +Test Files 5 passed (5) +Tests 39 passed (39) + +pnpm --filter @flatbread/core build +exit 0 + +pnpm test:ava -- --match='*content types*' +exit 0 +73 tests passed +note: the match command currently runs the broader AVA suite because of the root script's argument forwarding + +pnpm --filter nextjs build +exit 0 +note: build succeeds but prints the known eslint-plugin-react-hooks warning + +pnpm lint +exit 0 +All matched files use Prettier code style! +``` + +## Follow-up issue drafts + +### Follow-up: Add typed selection builder for generated read API + +**Problem:** Selection strings are runtime GraphQL snippets, not typed +TypeScript selections. + +**Acceptance criteria:** + +- Generate a selection builder or typed projection API for collection reads. +- Compile-time tests reject unknown fields. +- Existing string selection remains documented as an escape hatch or is removed. + +### Follow-up: Tighten relation helper naming and examples + +**Problem:** `FlatbreadRelationTarget` and +`FlatbreadRelationTargetCollection` are useful but easy to confuse. + +**Acceptance criteria:** + +- Add generated JSDoc explaining each helper. +- Add examples for one-to-one and one-to-many relations. +- Ensure docs and generated names match the glossary. + +### Follow-up: Align nullability between GraphQL and read helper types + +**Problem:** GraphQL nullable list/member behavior is only approximately +represented by the read helper types. + +**Acceptance criteria:** + +- Derive nullability from the GraphQL schema for relation helpers. +- Add compile-time assertions for nullable singular, nullable list, and + non-null list relations. + +## Decision + +**Keep / iterate.** Type safety is a PMF-strengthening differentiator. The +generated content-model helpers and read API remove several weakly typed paths, +but the prototype still needs a typed selection story and sharper relation +helper documentation before it can be marketed as a fully type-safe read layer. diff --git a/docs/experiments/issue-164-export-trust-experiment.md b/docs/experiments/issue-164-export-trust-experiment.md new file mode 100644 index 00000000..3df7f332 --- /dev/null +++ b/docs/experiments/issue-164-export-trust-experiment.md @@ -0,0 +1,156 @@ +# Experiment: Issue #164 — export trust experiment + +## Question + +Does an explicit ownership story plus JSON/CSV export behavior make Flatbread +feel safer to adopt? + +## Demo prompt + +Use this prompt in interviews or demos after the posts/authors/tags quickstart. +Run command examples from `examples/nextjs`, where `flatbread.config.js` lives: + +1. Show raw source files: + - `examples/content/markdown/posts/example-post.md` + - `examples/content/markdown/authors/tony.md` + - `examples/content/markdown/authors/eva.md` +2. Show config-owned relations in `examples/nextjs/flatbread.config.js`. +3. Show the [data ownership story](../data-ownership.md). +4. Show the snapshot export APIs: + + ```ts + import { + exportCollectionsAsCsv, + exportCollectionsAsJson, + loadConfig, + } from 'flatbread'; + + const configResult = await loadConfig({ cwd: process.cwd() }); + + const json = await exportCollectionsAsJson(configResult, { + collections: ['Post', 'Author'], + }); + + const csv = await exportCollectionsAsCsv(configResult, { + collections: ['Post'], + }); + ``` + + See also: + + - [Data ownership](../data-ownership.md) + - [Snapshot export docs](../json-export.md) + +5. Explain the exit path: + - raw files remain usable without Flatbread; + - JSON snapshots preserve normalized IDs and refs; + - CSV flat views are spreadsheet-friendly; + - GraphQL documents/types preserve the app's read shapes. + +## Product self-review notes + +No external participant interview was available in this execution environment, +so these are product-review notes from the implemented demo path rather than +human interview findings. Treat them as project notes, not external validation. + +## Verification transcript + +Command (run from `examples/nextjs`): + +```bash +node --input-type=module - <<'NODE' +import { + loadConfig, + exportCollectionsAsCsv, + exportCollectionsAsJson, +} from 'flatbread'; + +const configResult = await loadConfig({ cwd: process.cwd() }); +const json = await exportCollectionsAsJson(configResult, { + collections: ['Post'], + pathRoot: process.cwd(), +}); +const csv = await exportCollectionsAsCsv(configResult, { + collections: ['Post'], + pathRoot: process.cwd(), +}); + +console.log(JSON.stringify(json.Post[0], null, 2).split('\n').slice(0, 12).join('\n')); +console.log('---CSV---'); +console.log(csv.Post.split('\n').slice(0, 2).join('\n')); +NODE +``` + +Trimmed output: + +```text +{ + "_content": { + "raw": "\nLorem ipsum\n" + }, + "_filename": "b.md", + "_path": "content/markdown/posts/b.md", + "_slug": "b", + "authors": [ + "1111", + "ab2c" + ], + "id": "2348fds-563fdh-59ddsd-3332-09876", +---CSV--- +id,_filename,_path,_slug,authors,category,controversial_opinions,rating,research_duration,slurp_factor,soups_tested,tags,temperature_preference,title +2348fds-563fdh-59ddsd-3332-09876,b.md,content/markdown/posts/b.md,b,1111;ab2c,,,44,,,,,,Test post B +``` + +| Prompt area | Trust signal | Remaining concern | +| --------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------- | +| Raw Markdown/YAML files | Strong: source of truth is visible in Git | Derived fields / overrides require Flatbread to recompute | +| JSON export API | Strong: preserves IDs/refs and validates graph first | API-only today; non-developers need a CLI | +| CSV export API | Medium: useful for spreadsheet review | Nested fields are omitted and relation arrays are joined IDs | +| GraphQL introspection/types | Medium: preserves app read contract | Requires a working Flatbread schema/server or generated artifacts | +| Data ownership docs | Strong: clearly states non-goals and exit surfaces | Needs runnable CLI examples once export commands exist | + +## Keep / kill / iterate + +**Iterate based on product self-review.** Export behavior appears to improve the +adoption-trust story because it turns "your files are yours" into concrete +artifacts: raw files, JSON snapshots, CSV flat views, and generated read +contracts. + +Do not market this as externally validated or as a complete non-developer export +workflow yet. The trust story becomes materially stronger when JSON/CSV export +has a first-class CLI and when docs include copy-paste commands that write files +to disk. + +## Follow-up issue drafts + +### Follow-up: Add `flatbread export` CLI for JSON and CSV + +**Problem:** Export is currently an API, so adoption demos require a Node script. + +**Acceptance criteria:** + +- `flatbread export json --collections Post,Author --out snapshots/` +- `flatbread export csv --collections Post --out snapshots/` +- Commands fail with validation diagnostics for broken refs/duplicate IDs. +- Docs use CLI first and API second. + +### Follow-up: Add exit-story fixture output + +**Problem:** Docs describe export behavior but do not check in example output. + +**Acceptance criteria:** + +- Add a small `examples/exit-story/` fixture or generated snapshot directory. +- Include JSON and CSV outputs from the posts/authors/tags model. +- Add a test that verifies snapshots are deterministic. + +### Follow-up: Interview with two target users + +**Problem:** This report contains product-review notes, not external user +feedback. + +**Acceptance criteria:** + +- Run the demo prompt with at least two TypeScript/static-site developers. +- Record whether JSON/CSV exports increase adoption trust. +- Capture objections and update keep/kill/iterate decision. diff --git a/docs/experiments/issue-167-effort-graph-layout-mapping.md b/docs/experiments/issue-167-effort-graph-layout-mapping.md new file mode 100644 index 00000000..f7f95a6a --- /dev/null +++ b/docs/experiments/issue-167-effort-graph-layout-mapping.md @@ -0,0 +1,159 @@ +# Experiment: Issue #167 — Effort Graph reference layout (agent artifacts → indexed graph) + +**Scope:** Map one real in-repo agent artifact layout to the [Effort Graph sketch](../../flatbread-agent-artifact-opportunity.md) (§8): `Effort` → `Plan`, `Session`, `Decision` with `refs`. Demonstrate a **single retrieval surface** query—here, **GraphQL**—that returns **blocking decisions** for a chosen effort with **nested plan and session context**. This satisfies the “reference layout indexed + validated” bar from the [PMF decision rubric](../pmf-decision-rubric.md) as an **experiment**, not a shipped preset. + +**Non-goals (explicit):** Full **Session** / **Run** fidelity, importer scripts, or turning this repo’s proof harness into production artifact storage. GraphQL is **one** interface; the same filter object is intended to work against codegen-backed TypeScript or MCP when those surfaces expose the shared filter DSL ([§9 agent artifact opportunity](../../flatbread-agent-artifact-opportunity.md)). + +--- + +## 1. Source layout mapped (agent artifacts) + +**Canonical folder:** [`.cursor/skills/proof/`](../../.cursor/skills/proof/) — Cursor **Skill** for DAG-style proof runs. + +| Existing path | Role in harness | Effort Graph mapping | +| -------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `SKILL.md` | Human + agent docs for the skill | **Unindexed narrative** in v1; optional later **`Artifact`** row body or symlinked markdown | +| `examples/dag-flatbread-flow-pmf-audit.json` | Machine-authored **DAG spec** (title, tasks, models) | **`Plan`** row: `title` + provenance; body summarizes DAG; `source_artifact` frontmatter points back to this path | +| _(synthetic)_ proof CLI invocation | One **multi-step run** with canvas streaming | **`Session`** row: `runner`, `effort` ref, short body describing the run surface | +| _(synthetic)_ governance row | **Blocking** acceptance check | **`Decision`** row: `blocking`, `effort` / `plan` / `session` refs | + +This is **incremental adoption**: only new markdown under a dedicated tree needs frontmatter; harness files **stay in place** ([agent artifact opportunity §9.6](../../flatbread-agent-artifact-opportunity.md)). + +--- + +## 2. Target tree (preset-shaped) + +Representative fixtures live under: + +[`fixtures/cursor-proof-skill-effort-graph/`](./fixtures/cursor-proof-skill-effort-graph/) + +Suggested production mirror (from §8 sketch): + +```text +.flatbread-efforts/ + efforts/ + plans/ + sessions/ + decisions/ +``` + +--- + +## 3. Minimal `flatbread` config excerpt + +Wire the content arrays to the fixture paths (or to `.flatbread-efforts/*` once copied into a consumer repo): + +```javascript +import { defineConfig, transformerMarkdown, sourceFilesystem } from 'flatbread'; + +export default defineConfig({ + source: sourceFilesystem(), + transformer: transformerMarkdown({ markdown: { gfm: true } }), + content: [ + { + path: 'docs/experiments/fixtures/cursor-proof-skill-effort-graph/efforts', + collection: 'Effort', + }, + { + path: 'docs/experiments/fixtures/cursor-proof-skill-effort-graph/plans', + collection: 'Plan', + refs: { effort: 'Effort' }, + }, + { + path: 'docs/experiments/fixtures/cursor-proof-skill-effort-graph/sessions', + collection: 'Session', + refs: { effort: 'Effort' }, + }, + { + path: 'docs/experiments/fixtures/cursor-proof-skill-effort-graph/decisions', + collection: 'Decision', + refs: { effort: 'Effort', plan: 'Plan', session: 'Session' }, + }, + ], +}); +``` + +**Validation story:** Today, **broken `refs`** (typos in `effort` / `plan` / `session`) surface as missing relations at query time; duplicate `id` values within a collection remain a **roadmap** hardening item ([PMF audit §4](../../flatbread-flow-pmf-audit.md), [rubric](../pmf-decision-rubric.md)). + +--- + +## 4. Example query — one retrieval surface (GraphQL) + +**Intent:** “All **blocking** decisions for effort `pmf-audit-dag`, with **plan title** and **session** context.” + +```graphql +query BlockingDecisionsForEffort { + allDecisions( + filter: { effort: { eq: "pmf-audit-dag" }, blocking: { eq: true } } + sortBy: "decided_at" + order: DESC + ) { + id + title + status + blocking + decided_at + plan { + id + title + source_artifact + } + session { + id + runner + } + } +} +``` + +**Expected shape (illustrative):** One row for the #167 **reference layout** decision, with nested `Plan` matching the DAG JSON title and `Session` describing a proof-cli style run. **TS / MCP parity:** use the same `filter` JSON against the list resolver the app exposes ([agent artifact opportunity §9](../../flatbread-agent-artifact-opportunity.md)). + +--- + +## 5. Friction observed (concrete follow-ups) + +### Issue draft: **[Preset] Effort Graph field naming and codegen** + +**Problem:** GraphQL and docs benefit from **one canonical naming** policy (`blocking` vs `severity`, `decided_at` vs `decidedAt`). Today’s default `fieldNameTransform` only normalizes **spaces**, not snake_case→camelCase. + +**Acceptance criteria:** Document preset field names; optionally ship `fieldNameTransform: lodash.camelCase` for Effort Graph preset only; regenerate example GraphQL operations. + +--- + +### Issue draft: **[Core] Ref integrity diagnostics for agent presets** + +**Problem:** Missing `plan` / `session` on a blocking decision is a **product risk** ([rubric integrity bar](../pmf-decision-rubric.md)); today users discover gaps via empty nested selections, not necessarily a validator error. + +**Acceptance criteria:** Configurable **hard fail** (or structured diagnostic) when `Decision.blocking: true` and `plan` ref does not resolve; integration test from `diag-query-surface` notes. + +--- + +### Issue draft: **[MCP] Single-call “blocking decisions + context” for effort id** + +**Problem:** Agents should not re-learn GraphQL shapes per repo. + +**Acceptance criteria:** MCP tool accepts `effortId`, returns the same object shape as the query above (or executes the shared filter internally). + +--- + +### Project note (no issue number) + +**Canonical effort identity** (branch vs slug vs GitHub `#167`) is still a **human checkpoint**; this fixture uses **`pmf-audit-dag`** as a stable slug and **`external_issue: "167"`** on the Effort row for traceability. + +--- + +## 6. How to extend without migration day one + +1. Add **one** `Effort` row per research thread or feature. +2. When a DAG JSON exists, add a **Plan** row pointing at the file path in `source_artifact`. +3. For each proof run worth querying later, add a **Session** row. +4. Add **Decision** rows only for gates that must be machine-queryable (blocking / open decisions). + +--- + +## References + +- [flatbread-agent-artifact-opportunity.md §8 sketch](../../flatbread-agent-artifact-opportunity.md) +- [flatbread-flow-pmf-audit.md — Effort Graph positioning](../../flatbread-flow-pmf-audit.md) +- [pmf-decision-rubric.md](../pmf-decision-rubric.md) +- Proof DAG example: [`.cursor/skills/proof/examples/dag-flatbread-flow-pmf-audit.json`](../../.cursor/skills/proof/examples/dag-flatbread-flow-pmf-audit.json) diff --git a/docs/experiments/issue-168-adversarial-multi-layout-schema.md b/docs/experiments/issue-168-adversarial-multi-layout-schema.md new file mode 100644 index 00000000..8c97781d --- /dev/null +++ b/docs/experiments/issue-168-adversarial-multi-layout-schema.md @@ -0,0 +1,95 @@ +# Experiment: Issue #168 — Adversarial Effort Graph schema across three harness layouts + +**Scope:** Execute [agent artifact opportunity §12.2](../../flatbread-agent-artifact-opportunity.md) — stress one **Effort Graph**–shaped model against **three** representative tool trees. Document which **entities and fields** stay stable, which need **tool-specific mapping**, and whether **one canonical schema + a mapping layer** remains a viable product bet. + +**Product framing:** Flatbread is **Git-native relational content** for TypeScript apps, materialized from flat files. **GraphQL** is **one** query adapter alongside generated TypeScript and MCP; it does not define the whole product. + +**Related:** Issue [#167 reference layout](./issue-167-effort-graph-layout-mapping.md) (single Cursor `proof` skill → indexed rows). This report generalizes that pattern across layouts. + +**Non-goals:** Importers, core validator code, or moving production harness files. **Fixtures are snippets** under [`fixtures/issue-168-three-layout-snippets/`](./fixtures/issue-168-three-layout-snippets/). + +--- + +## 1. Harness layouts under test + +| ID | Layout | Representative paths (this repo or synthetic) | Role in adversarial test | +| ------ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| **L1** | **Claude Code–oriented** (skills / agent packets) | [`.agents/skills/*/SKILL.md`](../../.agents/skills/) | YAML frontmatter + narrative body; skills as discoverable units without a single DAG file per effort | +| **L2** | **Cursor rules + skills** | [`.cursor/rules/*.mdc`](../../.cursor/rules/), [`.cursor/skills/proof/`](../../.cursor/skills/proof/) | Split between **rules** (policy) and **skills** (workflows + JSON DAG examples) | +| **L3** | **GCC-style branch context** (synthetic) | [`fixtures/issue-168-three-layout-snippets/layout-gcc/`](./fixtures/issue-168-three-layout-snippets/layout-gcc/representative-tree.md) | Per-branch knowledge tree; identity and merge semantics are the stressor | + +The machine-readable **acceptance matrix** (checkbox test contract) lives in [`acceptance-test-matrix.md`](./fixtures/issue-168-three-layout-snippets/acceptance-test-matrix.md). + +--- + +## 2. Canonical schema (held constant across layouts) + +Collections (names align with [#167](./issue-167-effort-graph-layout-mapping.md) and opportunity §8): + +- `Effort` — thread of work; stable slug `id`; optional `external_issue`, `external_branch` +- `Plan` — structured intent; `title`, `source_artifact`, `effort` ref +- `Session` — one run / invocation; `runner`, `effort` ref +- `Decision` — gate; `blocking`, `status`, `effort` / `plan` / `session` refs +- `Artifact` _(optional in v1)_ — indexed file bodies (rules, manifests) when teams choose not to treat them as narrative-only + +--- + +## 3. Entity and field stability + +### 3.1 Stable across layouts (same semantic column in the graph) + +| Entity | Fields / behavior | Why stable | +| --------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| `Effort` | `id` (slug), optional `external_*` | Chosen **canonical identity**; tools do not agree on branch vs issue — the row **declares** the slug | +| All row types | `refs` to other collections (`effort`, `plan`, `session`) | Relational shape is the product promise | +| `Decision` | `blocking`, `status`, temporal fields (e.g. `decided_at`) | Gate semantics are layout-agnostic once ingested | +| Query surfaces | Filter object over the same field names (GraphQL / TS / MCP) | One mental model for consumers | +| **Disk policy** | New markdown under `.flatbread-efforts/` (or preset path); harness files **unmoved** | Matches [#167 incremental adoption](./issue-167-effort-graph-layout-mapping.md) and migration notes from upstream diagnostics | + +### 3.2 Requires tool-specific mapping (profile / ingest rules) + +| Concern | L1 Claude-oriented | L2 Cursor | L3 GCC | +| ------------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| **Where “the plan” lives** | Often **narrative** `SKILL.md` or distributed docs; may lack one JSON DAG | **Split**: rules vs `SKILL.md` vs `examples/*.json` | Session / design files per branch; path encodes **branch** | +| **Plan row `source_artifact`** | Globs on skill roots; may need **multi-file** summary or primary file pick | Point to **JSON** for machine title; optional second row for skill narrative | Map branch-relative path → repo-relative at ingest time | +| **Session identity** | CLI / agent-reported run id varies | proof CLI / IDE session labels | GCC **run** or commit-scoped ids (tool-defined) | +| **Rules / manifests** | Less central in stub skills | `.mdc` with `alwaysApply` — candidate **`Artifact`** or excluded | Policy files may mirror branch | +| **Effort identity collision** | Skill name vs GitHub issue | Branch name vs `pmf-audit-dag` slug | **Branch name vs slug** — highest duplication risk when branches fold | + +--- + +## 4. Where a single schema breaks (unless mapping layer exists) + +1. **Identity:** Without a documented **canonical `Effort.id`** and `external_*` fields, the same work is forked across tools ([§5 human checkpoint #167](./issue-167-effort-graph-layout-mapping.md)). +2. **Partial graphs:** Blocking `Decision` rows with missing `plan` / `session` refs are worse when three layouts multiply ingest paths — **validation / diagnostics** become product-critical (per upstream **diag-stability-mapping**). +3. **Noise:** Promoting every rule file to `Artifact` explodes row count; needs **`kind`** + **`always_on`** (or equivalent) filtering policy. +4. **GCC lifecycle:** Branch merge does not imply graph merge — **Sessions**, **Efforts**, and **links** need team policy (no automatic semantics in core). + +None of these require **abandoning** a unified collection schema; they require **profiles** (globs, field extraction, optional joins) and **integrity rules**. + +--- + +## 5. Recommendation + +**Verdict: One canonical Effort Graph schema + an explicit mapping / profile layer is viable.** The opportunity is **not** too fragmented for a single relational model: the fragmentation is in **harness conventions and identity policy**, not in the core nouns (`Effort`, `Plan`, `Session`, `Decision`). + +- **Ship** a small set of **layout profiles** (at minimum: Claude-oriented skills tree, Cursor rules+skills, GCC branch tree) as **configuration**, not separate schemas. +- **Invest** early in **ref integrity diagnostics** and **blocking-decision invariants** so multi-layout ingest cannot silently degrade. +- **Defer** promising automatic merge semantics for GCC branches until a human policy is written. + +If the team cannot commit to **canonical slugs** and **validation**, the same schema technically works but **operational** fragmentation will **feel** like multiple products — that is a **process** failure mode, not a schema impossibility. + +--- + +## 6. Traceability and human gate + +- Align this experiment with the real tracker issue **#168** (scope, acceptance criteria, and whether it stays distinct from **#167** documentation). +- Before scaling fixtures: approve **`Effort.id`** scheme and whether `.mdc` / root manifests are **`Artifact` rows** vs narrative-only ([#167 §5](./issue-167-effort-graph-layout-mapping.md)). + +--- + +## References + +- [flatbread-agent-artifact-opportunity.md §12](../../flatbread-agent-artifact-opportunity.md) +- [issue-167-effort-graph-layout-mapping.md](./issue-167-effort-graph-layout-mapping.md) +- [PMF decision rubric](../pmf-decision-rubric.md) diff --git a/docs/experiments/issue-169-agent-artifact-retrieval-benchmark.md b/docs/experiments/issue-169-agent-artifact-retrieval-benchmark.md new file mode 100644 index 00000000..82b24d19 --- /dev/null +++ b/docs/experiments/issue-169-agent-artifact-retrieval-benchmark.md @@ -0,0 +1,150 @@ +# Experiment: Issue #169 — cold-start vs Flatbread-mediated artifact retrieval + +## Question + +Does a Flatbread-style Effort Graph retrieval surface reduce prompt/context +cost while preserving enough continuity to justify further MCP and agent-query +investment? + +## Benchmark setup + +Representative artifact set: + +- `flatbread-agent-artifact-opportunity.md` +- `docs/experiments/issue-167-effort-graph-layout-mapping.md` +- `docs/experiments/issue-168-adversarial-multi-layout-schema.md` +- Effort Graph fixture rows under + `docs/experiments/fixtures/cursor-proof-skill-effort-graph/` + +Task prompt: + +> For the PMF audit DAG effort, identify open blocking decisions and include +> linked plan/session context. + +## Strategies compared + +### A. Cold-start context stuffing + +Stuff the full strategy/experiment history into context: + +```text +flatbread-agent-artifact-opportunity.md +issue-167-effort-graph-layout-mapping.md +issue-168-adversarial-multi-layout-schema.md +``` + +Measured byte count: + +```text +19,750 flatbread-agent-artifact-opportunity.md + 7,658 issue-167-effort-graph-layout-mapping.md + 9,693 issue-168-adversarial-multi-layout-schema.md +37,101 total bytes +``` + +### B. Flatbread-mediated Effort Graph retrieval + +Retrieve only the blocking decision row plus linked plan/session rows: + +```text +836 decisions/167-blocking-reference-layout.md +771 plans/flatbread-flow-pmf-audit-dag.md +618 sessions/proof-cli-session-20260508.md +2,225 total bytes +``` + +Representative query shape from #167: + +```graphql +query BlockingDecisionsForEffort { + allDecisions( + filter: { effort: { eq: "pmf-audit-dag" }, blocking: { eq: true } } + sortBy: "decided_at" + order: DESC + ) { + id + title + status + blocking + plan { + id + title + source_artifact + } + session { + id + runner + } + } +} +``` + +## Result + +| Strategy | Approx. bytes retrieved | Continuity quality | Cost / noise | +| ---------------------------- | ----------------------: | ------------------------------------------------------------------ | --------------------------------------------- | +| Cold-start stuffing | 37,101 | High context recall, but requires rereading broad strategy docs | High: 16.7× larger than filtered rows | +| Flatbread-mediated retrieval | 2,225 | Enough for the target question: blocking decision + plan + session | Low: focused payload, less repeated discovery | + +Filtered retrieval is roughly **94% smaller** for this task: + +```text +1 - (2,225 / 37,101) ≈ 94.0% +``` + +## Continuity tradeoff + +Flatbread-mediated retrieval answers the target question directly: + +- **Decision:** issue #167 reference layout remains an open blocking gate. +- **Plan context:** linked PMF audit DAG plan/source artifact. +- **Session context:** proof CLI/session row describing the run surface. + +What it loses: + +- Broad market landscape and strategic rationale from the full artifact + opportunity memo. +- Nuanced tensions from the adversarial schema report unless the query expands + to include related artifacts. + +That tradeoff is acceptable for "what is blocking this effort?" It is not +enough for "should Flatbread become an agent memory company?" without an +expanded query. + +## Recommendation + +**Keep / invest further.** The retrieval leverage is strong enough to justify +the next MCP/agent-query slice. A 94% smaller context payload with preserved +blocking decision continuity is exactly the kind of advantage the Effort Graph +opportunity needs. + +## Follow-up issue drafts + +### Follow-up: MCP query for blocking decisions by effort + +**Acceptance criteria:** + +- Tool accepts `effortId`. +- Returns blocking decisions with plan/session context. +- Uses Flatbread filters internally. +- Includes deterministic tests against the issue #167 fixture. + +### Follow-up: Expand artifact retrieval benchmark + +**Acceptance criteria:** + +- Use at least one multi-session real effort, not only representative fixtures. +- Compare answer quality for at least three prompts: + - blocking decisions; + - why a product choice was made; + - what to do next. +- Record token counts from an actual model/tool invocation. + +### Follow-up: Related-artifact expansion policy + +**Acceptance criteria:** + +- Define when a decision query should pull source artifacts, plan body, or full + strategy docs. +- Add max-depth and max-byte guardrails. +- Document recommended defaults for MCP calls. diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 00000000..d3493e65 --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,56 @@ +# Flatbread glossary — relational content primitives + +This page defines vocabulary for Flatbread’s **Git-native, flat-file relational content layer** for TypeScript apps. Flatbread turns files in your repo into a coherent **content graph** you can read from your application; it is **not** a hosted CMS, a full authoring product, or a general-purpose database. + +**[GraphQL](https://graphql.org/)** is often the **default query interface** in typical setups, but it is one way to read the graph—not the product’s whole identity. + +See also: [Flatbread positioning](./positioning.md); [PMF decision rubric](./pmf-decision-rubric.md) (comparative criteria and agent-wedge signals). + +--- + +### Cardinality + +How many related items a field connects—whether a relation resolves to **one** related entry or **many** (for example, a single author versus a list of tag strings on a post). Cardinality shapes how the graph is exposed to your app (including generated GraphQL fields); it does **not** imply a SQL-style database engine. + +Current relation cardinality rules are intentionally small: + +- **One-to-one:** a `refs` field whose content value is a single ID (`author: 2a3e`) resolves to one related record. +- **One-to-many:** a `refs` field whose content value is a list of IDs (`authors: [2a3e, 40s3]`) resolves to a list of related records. +- **Many-to-many:** model each side as one-to-many lists when both collections need to point at each other; Flatbread does not infer a hidden join table or reciprocal edge. +- **Unsupported / invalid:** booleans, objects, nested arrays, and other non-ID shapes in a `refs` field fail validation before schema use instead of silently resolving to `null`. + +### Tag (facet) vs `Tag` collection + +A **facet** is metadata stored on a record (often a **YAML list of strings** such as `tags: [a, b]` on a post). It becomes a **scalar list** in the read interface and is **not** the same as **`refs`** resolving to another collection. A **`Tag` collection** means one file per tag (or equivalent) and **`refs`** from **`Post`** → **`Tag`** so tag entries are **normalized records** in the graph—use that when tags need shared descriptions, stable ids, or relational edges of their own. + +### Collection + +A **named group** of content of the same kind, declared in your Flatbread config and usually mapped to a folder of source files (for example, all posts under `content/posts`). A collection is a **modeling unit** over files in Git, not a database table or a hosted content bucket. + +### ID + +An identifier Flatbread uses to **point at one item within a collection** so relations can resolve. Today, Flatbread expects loaded entries to expose an `id`-shaped value that query arguments and `refs` can compare against; future ID work should keep that rule explicit across files, generated types, and query interfaces. IDs wire the graph together **in the repository**; they are not a centralized “primary key service” like a server database would provide. + +Current normalization rule: IDs may be **non-empty strings** or **finite numbers**. Flatbread compares record lookup arguments through a normalized string form, so a record with `id: 123`, a GraphQL argument `id: "123"`, and a GraphQL `ID` integer literal `id: 123` refer to the same record. Top-level equality and membership filters on a collection record’s `id` use the same normalized comparison; ordered filters (`lt`, `gt`, etc.) continue to use normal scalar comparison and should not be treated as stable ID semantics. String IDs are trimmed before comparison, so `id: " 123 "` normalizes to `"123"`. Empty strings, `null`, `undefined`, booleans, objects, `NaN`, and infinite numbers are rejected as invalid record IDs; if more than one record is invalid, Flatbread reports the invalid IDs together. Duplicate IDs after normalization (for example `123` and `"123"` in the same collection) are invalid because they would otherwise resolve inconsistently. + +### Query interface + +The **API surface your application uses to read** the built content graph. In many projects today that surface is **GraphQL** (schema plus operations, often with codegen), meaning GraphQL is **an interface**, not the definition of Flatbread. Other ways to consume the same graph may exist in your stack alongside it. + +### Generated schema and operation types (GraphQL) + +When GraphQL is your **query interface**, the **generated GraphQL schema** describes how **collections** and fields are exposed at read time: list fields such as `allPosts` / `allAuthors` correspond to **collections**; nested selections follow **`refs`** (**relations**) and resolve to related **records**; scalar list fields that come from frontmatter (for example **`tags`** on a post) align with **Tag (facet)** in this glossary—not a **`Tag` collection** unless you add one. + +**Generated TypeScript** from GraphQL document codegen (for example operation result types such as `GetPostsAuthorsAndTagsQuery`) types **that read path only**. It does not redefine Flatbread’s domain model: the **records** and **relations** still originate in repo files and config. A future non-GraphQL generated TypeScript read surface, if shipped, would be documented separately so it does not blur this boundary. + +### Record + +**One loaded item** in a collection: the structured result of reading a file (metadata, body, derived fields) that your app treats as a single unit. “Record” here means **a document-shaped object in memory**, not a row in a remote database. + +### Relation + +A **configured link** from entries in one collection to another (for example, `refs` in config mapping a post field to an `Author` collection). Relations express **associations between flat-file content**, not foreign keys managed by a separate database server. + +### Validation + +Checks that your **Flatbread configuration, plugin wiring, and loaded content graph** are consistent enough to read safely. Near-term validation work should make broken references, duplicate IDs, and unsupported relation shapes clear before they become query-time surprises. This is still scoped to Flatbread’s content graph; it is not a promise of every database constraint or every editorial rule a CMS might enforce. diff --git a/docs/json-export.md b/docs/json-export.md new file mode 100644 index 00000000..c93c6966 --- /dev/null +++ b/docs/json-export.md @@ -0,0 +1,79 @@ +# Snapshot export + +Snapshot exports are part of Flatbread's data ownership story: they turn the +same repo-backed content graph into portable review artifacts. See +[data ownership and exit story](./data-ownership.md) for how raw files, Git +history, JSON/CSV exports, GraphQL introspection, and generated types fit +together. + +`@flatbread/core` exposes `exportCollectionsAsJson(configResult, options)` for +stable collection snapshots and `exportCollectionsAsCsv(configResult, options)` +for flat collection views. They are currently API surfaces rather than CLI +commands. + +## Stability contract + +- Selected collection names are sorted by Unicode codepoint order. +- Records are sorted by normalized record ID. +- Object keys are sorted recursively by Unicode codepoint order. +- Record IDs and configured relation fields use Flatbread's normalized ID + semantics. +- `_path` is emitted relative to `options.pathRoot` (default: + `process.cwd()`); `_filename`, `_slug`, and transformer-provided fields are + preserved. +- ID and reference validation runs before export output is returned, so broken + refs and duplicate IDs fail the same way they fail schema generation. + +## Example + +```ts +import { exportCollectionsAsJson } from '@flatbread/core'; +import { loadConfig } from '@flatbread/config'; + +const configResult = await loadConfig({ cwd: process.cwd() }); +const snapshot = await exportCollectionsAsJson(configResult, { + collections: ['Post', 'Author'], + pathRoot: process.cwd(), +}); + +console.log(JSON.stringify(snapshot, null, 2)); +``` + +## Current scope + +- JSON export is read-only; it does not mutate source files. +- Relation values are exported as normalized IDs, not expanded nested records. +- Source metadata is included today so snapshots are actionable during review. + A future option may strip `_path` / `_filename` for content-only diffs. + +## CSV flat views + +CSV export is intentionally a flat view over the same validated JSON snapshot: + +- scalar fields become columns; +- scalar arrays and relation-id arrays are joined with `;` by default; +- relation fields remain normalized reference IDs rather than expanded records; +- nested objects such as `_content` are omitted because they do not yet have a + stable flat representation. +- the delimiter defaults to `,`; `;` and tab are also supported; +- joined array/relation values default to `;`, configurable with + `relationSeparator`. + +```ts +import { exportCollectionsAsCsv } from '@flatbread/core'; + +const csv = await exportCollectionsAsCsv(configResult, { + collections: ['Post'], + delimiter: ',', + relationSeparator: ';', +}); + +console.log(csv.Post); +``` + +Example output: + +```csv +id,_filename,_path,_slug,author,authors,tags,title +known-post,known-post.md,content/posts/known-post.md,known-post,known-author,known-author,known-tag,Post With Resolved Refs +``` diff --git a/docs/local-dev-loop.md b/docs/local-dev-loop.md new file mode 100644 index 00000000..894f6ca3 --- /dev/null +++ b/docs/local-dev-loop.md @@ -0,0 +1,135 @@ +# Local dev loop and watch boundaries + +Flatbread's local loop has four moving parts: + +1. **Loader reload** — source plugins read flat files from the configured + content paths. +2. **Schema rebuild** — `@flatbread/core` turns loaded records and refs into a + GraphQL schema after ID/ref validation. +3. **Codegen refresh** — `flatbread codegen --watch` regenerates TypeScript + artifacts when config, content, or GraphQL documents change. +4. **Framework restart / refresh** — `flatbread start -- ` + runs the GraphQL server beside your app command. + +Today these pieces are partly automated. Codegen has a watch loop; the +GraphQL server started by `flatbread start` still builds its schema at process +startup. That means some edits update generated TypeScript automatically, while +runtime query behavior still needs a restart until the server grows a live +schema swap. + +## Canonical Next.js happy path + +From the repo root: + +```bash +pnpm install +pnpm build +cd examples/nextjs +pnpm exec flatbread codegen --verbose +``` + +For development, use two terminals. This path avoids the example package's +HTTPS convenience script and keeps the Flatbread GraphQL endpoint on plain HTTP +port `5057`. + +```bash +# terminal 1 — regenerate TypeScript artifacts +pnpm exec flatbread codegen --watch --verbose +``` + +```bash +# terminal 2 — serve GraphQL + Next.js without HTTPS for headless/dev agents +pnpm exec flatbread start -- next dev --turbopack +``` + +Expected behavior: + +- Editing a `.graphql` document or a content/config file triggers the codegen + watcher and updates `generated/graphql.ts`. +- The generated content-model types and prototype read API are refreshed by + the same codegen command. +- The running GraphQL endpoint at `http://localhost:5057/graphql` continues to + use the schema it built at startup. +- Restart `pnpm exec flatbread start -- next dev --turbopack` after changing + content, refs, collection config, transformers, or validation-sensitive data + if you need the live endpoint/app render to reflect the new graph. + +## Current reload matrix + +| Change | Codegen watcher behavior | Running GraphQL server | Framework app | Action required today | +| --------------------------------------- | ------------------------------------------- | ------------------------------------------------ | -------------------------------------------------------- | -------------------------------------------------------- | +| Markdown/YAML field value | Regenerates if watched path matches | Keeps previous startup schema/data | Keeps rendering whatever the endpoint returns | Restart `flatbread start` to update live query results | +| New/removed content file | Regenerates if watched path matches | Keeps previous startup schema/data | Keeps rendering whatever the endpoint returns | Restart `flatbread start` to update live query results | +| `.graphql` document | Regenerates operation types | No restart unless query text used by app changed | Framework dev server normally recompiles importing files | No Flatbread restart unless app code needs it | +| `flatbread.config.*` content/ref change | Attempts config reload from the current cwd | Keeps previous startup schema/data | Keeps rendering whatever the endpoint returns | Restart `flatbread start`; run watcher from config dir | +| Transformer/source package code | Does not rebuild package code | Keeps previous imported package code | May keep previous imported package code | Rebuild/watch package separately, rerun codegen, restart | +| `generated/graphql.ts` | Output of codegen | No direct effect | Framework dev server recompiles imports | No Flatbread restart | + +## Failure semantics today + +- If content becomes invalid while `flatbread codegen --watch` is running, the + watcher logs the validation/codegen error and keeps watching. Existing + generated files are left as-is until a later successful regeneration. +- In one-shot mode (`flatbread codegen` without `--watch`), validation or + codegen errors exit non-zero and do not prove the live server changed. +- If the running GraphQL server was started before the invalid edit, it keeps + serving the schema/data it already loaded. Restarting it surfaces the + validation error at startup. +- There is no partial hot-swap mode yet: generated TypeScript can refresh while + the live GraphQL server remains on the old content graph. + +## Draft unified watch design (not implemented) + +The unified loop should eventually make this one command: + +```bash +flatbread start --watch -- next dev --turbopack +``` + +Design contract: + +1. Watch the same content/config/document paths that `flatbread codegen --watch` + already derives from `LoadedFlatbreadConfig`. +2. On content changes, reload records, rerun ID/ref/cardinality validation, + rebuild the schema, refresh generated TypeScript, and swap the GraphQL + server schema only if the new graph validates. If validation fails, keep the + previous schema active and log the failure. +3. On config changes, reload config, rebuild watch globs, rebuild schema, + refresh generated TypeScript, and restart only the Flatbread GraphQL server + boundary if a safe hot swap is not possible. A safe hot swap means replacing + schema/data without losing the child framework process, open port, or + in-flight request handling state. +4. On GraphQL document changes, refresh generated TypeScript only. +5. Keep framework restarts explicit. Flatbread should not assume every + framework can be restarted safely; it should document whether the app command + is left running, restarted, or expected to recompile through its own dev + server. + +## Known limitations + +- `flatbread start` does **not** currently hot-swap schema or content. +- `flatbread codegen --watch` is a long-running process; do not use it in CI or + one-shot scripts. +- The Next.js example `pnpm dev` includes `--https` for local convenience, but + the Flatbread GraphQL endpoint remains documented as HTTP on `5057`. In + headless environments prefer `pnpm exec flatbread start -- next dev --turbopack`. +- Generated TypeScript can update before the running GraphQL endpoint does. + Treat codegen success as a type artifact refresh, not proof that the live + server has reloaded. +- `flatbread.config.*` watching is relative to the process cwd today. Run + `flatbread codegen --watch` from the directory that contains the config. +- Port `5057` collisions are not resolved automatically; stop the old + Flatbread process before starting another server. + +## Follow-up implementation seams + +- Add a `flatbread start --watch` flag that composes schema reload and codegen + refresh. +- Factor codegen's watch-pattern derivation into a shared helper used by both + `@flatbread/codegen` and the CLI. +- Add an integration test that edits a fixture post and proves the GraphQL + endpoint returns the updated value without a manual restart once hot swap is + implemented. +- Add a current-behavior integration test that edits a fixture post and proves + the running server does **not** change until restart, so future hot-swap work + has a concrete test to flip. diff --git a/docs/pmf-decision-rubric.md b/docs/pmf-decision-rubric.md new file mode 100644 index 00000000..8bc9acfc --- /dev/null +++ b/docs/pmf-decision-rubric.md @@ -0,0 +1,79 @@ +# PMF decision rubric — Flatbread vs adjacent workflows + +This page supports product and positioning decisions (e.g. [issue #144](https://github.com/FlatbreadLabs/flatbread/issues/144)) by comparing **Flatbread** to four **buyer-recognizable** workflow families. Use it to avoid mixing “Flatbread vs SQLite” with “Flatbread vs Notion” in the same breath without naming who you are selling to. + +**Flatbread in one line:** Git-native **relational content** for TypeScript apps, **backed by flat files** in the repo. **[GraphQL](https://graphql.org/)** is a common **read interface** and codegen driver; it is **not** the whole product identity. The core artifact is the **modeled content graph** (collections, fields, relations, validation). + +--- + +## How to read the matrix + +Each row names a **workflow category** a buyer might already use. Columns are **decision criteria** aligned with validation experiments and near-term PMF work. Cells summarize typical tradeoffs **for that category**, not a single vendor scorecard. + +**Legend (qualitative):** + +- **Strong** — category usually excels here with little extra work. +- **Medium** — workable with discipline, tooling, or conventions; gaps are predictable. +- **Weak** — common pain or structural mismatch for this criterion in typical setups. +- **N/A** — criterion does not apply the same way (call out explicitly). + +Where Flatbread is **targeting** behavior that is not fully shipped yet (for example, first-class reference integrity at load time), the cell notes **current vs target** honestly. + +--- + +## Comparative matrix + +| Criterion | Flatbread (relational flat files) | SQLite-style database workflows | Hosted / headless CMS workflows | Contentlayer-like content workflows | Agent artifact / Effort Graph workflows | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| **Setup time** | **Medium** — deps, config, content paths, optional GraphQL server/codegen; goal is ~10 minutes to a typed read for a `posts → authors → tags` starter. | **Medium** — schema/migrations, client, connection; very fast for experienced DB users. | **Medium–High** — account, schema/content model, API keys, webhooks; low ops if fully hosted. | **Medium** — build plugin, schemas, content layout; familiar to static/SSG teams. | **High variance** — conventions differ (`AGENTS.md`, `.handoff/`, vaults); **relational** effort graphs rarely work out of the box. | +| **Type safety** | **Medium (moving target)** — generated types help at the query boundary; config and raw content surfaces may still be looser until model-first typing lands end-to-end. | **Strong** with SQL builders/ORMs; schema is the source of truth. | **Medium** — SDK/OpenAPI/GraphQL types help; CMS field types and draft content can weaken guarantees. | **Strong** for defined content schemas; weaker if everything is MDX/adhoc. | **Weak–Medium** — lots of markdown prose; typed edges often missing unless encoded manually. | +| **Relation modeling** | **Strong intent** — `refs`, nested reads, filters over a content graph; cardinality must stay **documented** (implied behavior is an audit gap). | **Strong** — joins and constraints are the database’s job. | **Medium–Strong** — reference fields and UI; deeper graph queries depend on API. | **Medium** — relations exist but are optimized for site content, not arbitrary graphs. | **Weak** — links and search, not always **foreign-key-style** relations across tool boundaries. | +| **Reference integrity** | **Target: Strong / Today: uneven** — buyers expect missing refs, duplicate IDs, and bad shapes to **fail with clear diagnostics at load/validate**, not silent GraphQL `null` chains; full guarantee is **roadmap-critical**, not optional polish. | **Strong** with constraints and transactions (or app-enforced). | **Medium–Strong** — CMS often blocks bad publishes; export/sync paths can still drift. | **Medium** — build fails on schema errors; cross-file refs vary by stack. | **Weak** — broken links and orphan artifacts are common; validation is not standardized. | +| **Portability** | **Strong (raw files + Git)** — export story should include **JSON/CSV per collection** as a deliberate trust lever; contrast with ad-hoc “query and save.” | **Strong** via `dump`, backups, SQL files; binary portability has ops nuance. | **Medium** — APIs and export formats; lock-in depends on vendor. | **Strong** — content lives in repo; migration is folder moves + schema rewrites. | **Strong** — everything is files; **semantic** portability across tools is harder than byte portability. | +| **Local dev loop** | **Medium (honest)** — file-backed by nature; **reliable hot reload of content is not a pillar yet**; expect restarts or manual steps today where examples require a server/codegen refresh. | **Strong** — migrations + local DB; ORM dev UX mature. | **Variable** — offline editing depends on sync; preview stacks add latency. | **Medium–Strong** — dev servers often rebuild on file change; watch modes vary. | **Strong for “save file”** — weak for “typed graph updates everywhere” without extra tooling. | +| **Agent query ergonomics** | **Medium (directional)** — predicate-rich filters and nested reads suit **structured** agent queries; today’s path often touches **GraphQL** or codegen; **MCP / generated TS** as first-class agent surfaces is PMF leverage, not a nice-to-have. | **Strong** — SQL is the universal agent substrate when access is allowed. | **Medium** — HTTP APIs; auth and rate limits add friction for agents. | **Medium** — build-time access is easy; **runtime** ad-hoc queries less natural. | **Weak–Medium** today — keyword/vault MCP and search; **Effort Graph**-style queries want relational filters + integrity. | + +--- + +## Named contrasts (avoid category mixing) + +When writing positioning or issues, **name the buyer** and **one primary alternative**: + +| If the buyer is deciding against… | Lead with… | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **SQLite / Postgres + app** | Versioned **content** and **review in Git** vs operational DB ergonomics; Flatbread is **not** replacing transactions or multi-writer DB semantics. | +| **Notion / Contentful / Sanity / etc.** | **Repo ownership** and **flat files** vs editorial APIs and hosted workflows; relations without standing up CMS infrastructure. | +| **Contentlayer / Velite / similar** | **Cross-collection references and graph reads** in TypeScript vs site-generation-first content pipelines. | +| **Handoff folders / vault MCP / memory tools** | **Typed relations and validation** over agent artifacts vs search-only or narrative-memory layouts—only after core integrity and watch/export bars are credible. | + +--- + +## Agent artifacts: secondary vertical vs primary wedge + +**Secondary vertical (default posture today)** fits when Flatbread’s **near-term bar** is still about relational **content** for apps—schemas, IDs, validation, exports, watch—and agent use inherits the same graph primitives without a bespoke **Effort Graph** product bundle. + +**Go signals for treating agent artifacts as primary wedge** + +- Reference integrity and diagnostics at **load/validate** are **trusted** on real repos (missing refs, duplicate IDs, invalid shapes fail loudly and actionably). +- **Model-first** onboarding reaches a **typed** query without demanding GraphQL literacy on day one; generated TypeScript and/or MCP cover the **agent-shaped** query path. +- **Watch** or an honest, low-friction loop makes **file edit → graph update** usable for harnesses that emit many small artifacts. +- At least one **reference layout** (for example `.agents/` or handoff-oriented trees) is documented as **indexed and validated** incremental adoption, not a migration cliff. +- Evaluation buyers consistently compare Flatbread to **vault/handoff/GCC** workflows—not only to CMS or Contentlayer—_and_ the graph answers queries like _blocking decisions for effort X with plan title_ without bespoke glue per repo. + +**No-go / hold signals (keep agent artifacts secondary)** + +- Broken links and duplicate IDs still **silently** degrade query results; buyers cannot distinguish “no data” from “bad graph.” +- The **only** documented happy path assumes a running **GraphQL** mental model for authors and agents. +- Local iteration still **requires full process restart** for ordinary content edits in the primary examples, with no credible watch/export story. +- Positioning drifts into **database replacement** or **hosted CMS** parity; agent narrative distracts from the core **TypeScript + Git relational content** promise. + +**Decision summary:** Agent artifacts are a **credible strategic option** because they amplify demand for the same integrity, typing, and query surfaces the core product needs; they become a **primary wedge** only when those properties are **proven in production-shaped workflows**, not declared in roadmap language alone. + +--- + +## Related docs + +- [Flatbread positioning](./positioning.md) — canonical product framing. +- [Glossary](./glossary.md) — collections, relations, IDs, validation, query interfaces. +- [Flatbread Flow PMF Audit](../flatbread-flow-pmf-audit.md) — evidence-backed gaps and near-term experiments. +- [Agent artifact opportunity](../flatbread-agent-artifact-opportunity.md) — Effort Graph and adjacent landscape (deeper than this rubric). diff --git a/docs/positioning.md b/docs/positioning.md new file mode 100644 index 00000000..4512628a --- /dev/null +++ b/docs/positioning.md @@ -0,0 +1,21 @@ +# Flatbread positioning + +Flatbread positions itself the same way across the repo; this page is a stable link target. For install and usage, see the [main README](../README.md). For vocabulary used across docs and config—**collections**, **relations**, **IDs**, and how a **query interface** fits in—see the [glossary](./glossary.md). For **buyer-aware comparisons** (SQLite-style workflows, CMSs, Contentlayer-like stacks, agent artifact graphs) across setup time, typing, integrity, and related criteria—plus **go / no-go** guidance for an agent-artifact wedge—see the [PMF decision rubric](./pmf-decision-rubric.md). For portability and exit paths, see [data ownership](./data-ownership.md). + +Turn flat files in Git into typed, relational content for your TypeScript app. The core artifact is an in-repo **content graph** (collections, records, **`refs`**). **Generated types plus [GraphQL](https://graphql.org/) operations** layer on top today as the most common **read interface** — they describe how many apps consume that graph at build/run time; they do not redefine what Flatbread **is**. + +**Flatbread** is a Git-native relational flat-file content layer for TypeScript apps. Your repo and filesystem are the source of truth; plugins (sources, transformers, and resolvers) extend how content is loaded and shaped. + +**Who it's for:** Teams shipping TypeScript sites, internal tools, and starters who want **versioned, reviewable content** and **relationships between entries**—without standing up a CMS database or giving up ownership of where content lives. + +**Non-goals:** + +- Not a hosted CMS, dashboard, or authoring UI: Flatbread is a library and local workflow, not a full content-management product you log into. +- Not a general-purpose GraphQL platform or a substitute for a general-purpose database (transactions, granular access control, and high-scale multi-writer workloads are out of scope). +- Reliable live reload of content while the dev server runs is [not a supported pillar yet](https://github.com/FlatbreadLabs/flatbread/issues/65); expect to restart to pick up file changes. + +**GraphQL:** In the default setup, GraphQL is a primary **interface** for reading an already-loaded content graph (`schema → operations → codegen`). Prefer thinking **files → model → typed read path** rather than treating GraphQL alone as Flatbread. For **traceability** from **backing files** (posts, authors, tag facets on posts) through **config** to generated schema and operation types—aligned with the [glossary](./glossary.md)—see the **Quickstart** and **Traceability** sections of [`packages/flatbread/README.md`](../packages/flatbread/README.md#quickstart-posts-authors-and-tags). + +**Portability and exit:** Raw files stay in Git, so content can be branched, reviewed, reverted, and migrated without asking a hosted CMS for a dump. JSON and CSV exports provide reviewable snapshots with normalized IDs and refs; GraphQL introspection and generated operation types preserve the read shapes your app used. The prototype generated read API is convenient inside Flatbread, while raw files, snapshots, GraphQL documents, and operation types are the durable exit surfaces. + +**Skimming from GraphQL-first experience:** Jump to **`refs` + relations** in [glossary](./glossary.md), then codegen and your app’s **`flatbread codegen`** docs — the relational layer is upstream of the queries you write. diff --git a/docs/proposals/proof-bounded-convergence-loops.md b/docs/proposals/proof-bounded-convergence-loops.md new file mode 100644 index 00000000..b516cff1 --- /dev/null +++ b/docs/proposals/proof-bounded-convergence-loops.md @@ -0,0 +1,206 @@ +# Proposal: First-class bounded convergence loops in `@flatbread/proof` + +Status: Implementation +Tracking: branch `toeknee/proof-bounded-loop-cde0` stacked on PR #177 + +## Why + +The earlier discussion on cyclic vs acyclic task graphs (cursor agent +`bc-0ff9d782-…`, run `run-3cc886ad-…`) settled on the position: + +- The dependency graph should stay acyclic (DAG `depends_on` edges are + about static causality and parallelism — letting `depends_on` form a + cycle destroys readiness, skip, and rank semantics for no benefit). +- "Cyclic flow" is real and useful — research → critique → refine, fix + → test → fix-until-oracle, write → review → patch — but it is + bounded refinement, not a back-edge in the dependency graph. + +`proof` already implements the right shape, just at the CLI: + +- `--converge-on ` + `--max-iterations ` re-executes the + named task plus its transitive ancestors with the previous result + stitched into ancestor prompts as `extraContext`. +- The loop body parses `## Blockers` and `## High-severity findings` + and exits when both are empty, otherwise marks the convergence task + `BUDGET-EXCEEDED` after exhausting the iteration cap. + +Three real limitations: + +1. **Only one convergence task per run.** The CLI flag is a singleton; + you cannot stack a code-review loop and a docs-review loop in the + same DAG. +2. **The "what to re-execute" set is hardcoded** to "all transitive + ancestors". For wide DAGs you often want to re-run only a focused + subset (say, the implementation task and the reviewer, not the + six independent research tasks at the root). +3. **The convergence config lives outside the DAG JSON.** A DAG + author who wants reproducible convergence has to remember to pass + the right CLI flags every run, and tooling that emits DAGs has no + way to declare loop intent. + +This proposal adds a first-class, DAG-native bounded loop primitive +that subsumes the CLI flag without breaking it. + +## What + +Add an optional top-level `DAG.loops` array. Each entry is a +`DAGConvergenceLoop`: + +```jsonc +{ + "title": "implementation + adversarial review", + "loops": [ + { + "id": "review-loop", + "convergeOn": "review", + "maxIterations": 3, + "reexecute": { "kind": "ancestors" } + } + ], + "tasks": [ + /* … */ + ] +} +``` + +### Schema + +```ts +export type LoopReexecute = + | { kind: 'ancestors' } + | { kind: 'tasks'; tasks: string[] }; + +export interface DAGConvergenceLoop { + /** Stable id for canvas/log display. Defaults to `loop-${convergeOn}`. */ + id?: string; + /** Task whose `## Blockers` / `## High-severity findings` drive the loop. */ + convergeOn: string; + /** Iteration ceiling. Iteration 0 is the original main-rank run. */ + maxIterations: number; + /** What to re-execute on each iteration. Defaults to `{ kind: 'ancestors' }`. */ + reexecute?: LoopReexecute; +} +``` + +### Validation rules + +- `convergeOn` must be a known task id. +- `maxIterations` must be a positive integer. +- For `reexecute.kind === 'tasks'`: every entry must be a known task + id; the set must be a subset of `transitiveAncestors(convergeOn) ∪ {convergeOn}` + (re-executing tasks outside the convergence ancestor cone breaks + topological re-execution order — explicit error rather than silent + divergence). Every non-`convergeOn` task in the list must also bring along + its own transitive ancestors so the rerun subset is dependency-closed. +- Two loops cannot share the same `convergeOn` (avoids ambiguous + iteration counter ownership). +- `id` must be unique across loops after defaults are applied, so an explicit + `id: "loop-review"` cannot collide with another loop whose defaulted id would + also be `loop-review`. +- Two loops must have disjoint re-execution sets. If they overlap, the parser + rejects the DAG rather than letting a later loop silently invalidate an + earlier loop's converged outcome. +- The CLI `--converge-on` flag is mutually exclusive with `DAG.loops` + — supplying both is an error rather than a silent precedence rule. + +### Runner behavior + +The existing `runConvergenceLoop` function generalizes: + +- Caller supplies an explicit `reExecIds` set instead of computing + `transitiveAncestors(convergeOn) ∪ {convergeOn}` inside the loop. +- The CLI flag synthesizes a single-element `loops` array so the same + code path covers both entry points. +- Multiple loops run sequentially (in declaration order). Each loop's + `BUDGET-EXCEEDED` propagates to the run-level outcome the same way + the single CLI loop does today. +- Runner restarts resume from the persisted convergence iteration counter + instead of replaying iteration numbers from `1`. +- `dag.budget.maxIterations` continues to work and applies to each + loop independently — it is a hard cap on the per-loop iteration + counter, not a global counter. + +### What is intentionally out of scope (this PR) + +- Alternate loop stop predicates. The existing parser + (`extractConvergenceFindings` in `converge_loop.ts`) is the only stop rule; + richer predicates (oracle-pass, numeric thresholds) can land in follow-ups + once there is a concrete runtime need. +- Nested loops (loop inside loop). The flat array is enough for + every workflow we have today. +- Cross-loop coordination (loop A waits on loop B's iteration N). + Same reasoning — no real demand and would force a bigger + scheduler rewrite. + +## Backward compatibility + +- DAG JSON without `loops` keeps parsing untouched. +- The CLI flags `--converge-on` and `--max-iterations` keep working + end-to-end. Their behavior is reimplemented as a synthesized + single-element loops array. +- `DAG.budget.maxIterations` keeps the same meaning (per-loop hard + cap) and the same `BUDGET-EXCEEDED` terminal status. +- The `extractConvergenceFindings` parser, the `findings-dir` + sidecar contract, and the `extraContext` stitching format are + unchanged. Existing reviewer prompts keep working. + +## Test plan + +Focused AVA tests (`packages/proof/src/__tests__/loops.test.ts`, runnable via +`pnpm -F @flatbread/proof test` and included in root `pnpm test`): + +- `parseDAG` accepts `loops` with default `reexecute`. +- `parseDAG` rejects `convergeOn` referencing an unknown task id. +- `parseDAG` rejects two loops with the same `convergeOn`. +- `parseDAG` rejects two loops with the same materialized `id` (including + defaulted `loop-${convergeOn}` collisions). +- `parseDAG` rejects `reexecute.tasks` containing unknown ids or ids + outside the convergence ancestor cone, and rejects non-closed subsets. +- `parseDAG` rejects overlapping loop re-execution sets. +- `parseDAG` rejects non-positive `maxIterations`. +- `resolveLoopReexecuteIds` returns the right id set for both + `'ancestors'` and explicit `tasks` modes. +- Re-execution rank filtering preserves topological order for the + filtered subset. + +Backward-compat smoke: + +- A DAG with no `loops` and no CLI `--converge-on` runs zero + convergence iterations (existing behavior). +- A DAG with no `loops` plus CLI `--converge-on` synthesizes one + loop and runs it. +- A DAG with `loops` plus CLI `--converge-on` errors at startup. + +Self-review via `/proof` is the user-facing acceptance test; this +PR's test plan above is what gates the merge. Contributor-facing command: + +```bash +pnpm -F @flatbread/proof test +``` + +## Migration + +No code changes required for existing DAG JSON. Authors who want +DAG-native convergence can move from: + +```bash +proof --dag run.json --converge-on review --max-iterations 3 +``` + +…to: + +```jsonc +// run.json +{ + "loops": [{ "convergeOn": "review", "maxIterations": 3 }], + "tasks": [ + /* … */ + ] +} +``` + +```bash +proof --dag run.json +``` + +The CLI form stays valid for ad-hoc runs. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 00000000..e10937f3 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,151 @@ +# Flatbread roadmap update from validation work + +This roadmap reflects the PMF audit, implementation work, and experiment +reports completed through the current project-board sequence. All verdicts below +rest primarily on internal evidence; external validation gates promotion past +**Iterate** on user-facing claims. + +## Evidence inputs + +- [PMF decision rubric](./pmf-decision-rubric.md) +- [PMF audit](../flatbread-flow-pmf-audit.md) +- [Agent artifact opportunity](../flatbread-agent-artifact-opportunity.md) +- [Positioning](./positioning.md) +- [Relational starter benchmark](./experiments/issue-162-relational-starter-benchmark.md) +- [TypeScript safety test](./experiments/issue-163-typescript-safety-test.md) +- [Export trust experiment](./experiments/issue-164-export-trust-experiment.md) +- [Effort Graph wire-up](./experiments/issue-167-effort-graph-layout-mapping.md) +- [Adversarial Effort Graph schema test](./experiments/issue-168-adversarial-multi-layout-schema.md) +- [Agent artifact retrieval benchmark](./experiments/issue-169-agent-artifact-retrieval-benchmark.md) + +## Keep / kill / iterate decisions + +| Initiative | Decision | Rationale | Next action | +| ----------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| Relation-first content layer | **Keep** | Starter path reaches install/build/codegen/demo query under 10 minutes in a fresh worktree; docs now lead with files → model → typed reads. | Polish example content and resolve Next.js ESLint warning. | +| ID/ref/cardinality validation | **Keep** | Normalized IDs, duplicate diagnostics, missing-ref validation, cardinality docs/tests, and snapshots now make integrity first-class. | Extract reusable validation API and add current/live server integration tests. | +| Generated TypeScript model/read API | **Iterate** | Generated model helpers and read API prove typed consumption is plausible, but selection typing and nullability need hardening before stable positioning. | Build typed selection/projection API and refine relation helper docs. | +| GraphQL interface | **Keep, repositioned** | GraphQL remains useful as schema/introspection/client interface, but docs now frame it as one read surface over the model. | Add schema/SDL export command or documented introspection artifact. | +| Local dev loop/watch | **Iterate** | Current codegen watch is useful, but `flatbread start` still needs restart for live content/schema changes. | Implement `flatbread start --watch` after design/test seams are pinned. | +| JSON/CSV portability exports | **Iterate** | Core APIs validate and export stable JSON/CSV views; trust story improves, but CLI and non-developer workflow are not complete. | Add `flatbread export json/csv` CLI and fixture outputs. | +| Agent artifact / Effort Graph | **Iterate — strong candidate wedge** | #167/#168 show schema+mapping is viable; #169 shows large context reduction for blocking-decision retrieval. Evidence is promising but still fixture-driven. | Build MCP query for blocking decisions and run a multi-session real-effort benchmark before making it the primary wedge. | +| Append/deposit write API | **Deferred** | Effort Graph may need append-oriented writes, but write scope is not validated enough to broaden beyond read/export surfaces. | Revisit only if Effort Graph moves toward primary wedge. | +| Hosted CMS / authoring UI | **Kill for now** | No validation required a hosted dashboard; it conflicts with the ownership/local-first wedge. | Do not schedule until core filesystem workflow is excellent. | +| General database replacement | **Kill for now** | Validation work strengthens content integrity but not transactions, auth, multi-writer, or operational DB semantics. | Keep non-goal language prominent. | + +## Updated priority order + +1. **Ship validation + type-safety foundation** — stabilize IDs, refs, + cardinality, snapshots, and generated model helpers. +2. **Make the canonical example excellent** — keep posts/authors/tags as the + first-success path; resolve example lint noise; ensure docs and generated + artifacts never drift. +3. **Add export CLI** — turn JSON/CSV APIs into copy-pasteable commands for the + ownership story. +4. **Implement unified watch loop** — move from documented restart boundaries to + `flatbread start --watch` with tests; this is also a precondition for + promoting Effort Graph beyond secondary vertical because agent artifact + folders change continuously. +5. **Prototype MCP / agent query surface** — start with blocking decisions by + effort ID and reuse the Effort Graph fixture. +6. **Run external validation** — repeat starter, type-safety, export-trust, and + agent retrieval experiments with humans or real multi-session efforts. + +## Agent artifact opportunity status + +**Decision:** keep Effort Graph as a **secondary vertical with a path to primary +wedge**. + +Reasoning: + +- The opportunity aligns with core Flatbread primitives instead of inventing a + separate product. +- The adversarial schema test found fragmentation in mapping profiles, not in + the core nouns. +- Filtered retrieval for blocking decisions was dramatically smaller than + context stuffing in the representative benchmark. +- Evidence is not yet external or multi-session enough to displace the broader + TypeScript relational content wedge. + +Gate to primary wedge: + +- MCP blocking-decision query works against a real multi-session effort. +- A token-based benchmark (not just bytes) confirms retrieval leverage. +- MCP and generated-TypeScript read paths reach parity with the GraphQL filter + shape on the #167 fixture. +- At least one external user/team records a saved rediscovery pass on a real + multi-session effort relative to its current vault/handoff/search workflow. + +## Follow-up issue drafts + +The current automation cannot create or close GitHub issues directly. These +drafts should be turned into issues/project notes by a maintainer: + +1. **Add export CLI for JSON/CSV snapshots** + - `flatbread export json --collections Post,Author --out snapshots/` + - `flatbread export csv --collections Post --out snapshots/` +2. **Implement `flatbread start --watch`** + - schema/content reload, codegen refresh, and failure semantics from + `docs/local-dev-loop.md`. +3. **Add MCP Effort Graph query** + - `blockingDecisions(effortId)` returning decision + plan + session context. +4. **Run network-cold starter benchmark** + - fresh clone/container with cold pnpm store. +5. **Run external export trust interviews** + - at least two TypeScript/static-site developers. +6. **Typed selection builder for generated read API** + - remove or isolate the string-selection escape hatch. +7. **Schema/introspection export artifact** + - check in or command-print GraphQL SDL/introspection for exit workflows. +8. **Harness mapping profiles** + - ship Claude-oriented, Cursor-oriented, and GCC-style Effort Graph mapping + profiles as configuration rather than separate schemas. +9. **External validation interview set** + - starter, export trust, TypeScript safety, and Effort Graph retrieval runs + with non-maintainer users. + +## Maintainer action checklist + +1. Create follow-up issues/project notes from the drafts above. +2. Close or split project-board issues according to the traceability table + below. +3. Confirm whether any issue should remain open because acceptance requires + external validation this branch could only draft. +4. Update project-board priority lanes to match the "Updated priority order" + section. + +## Closed / completed project-board issues in this stack + +| Issue | Evidence artifact / commit area | Proposed status | +| ----- | ------------------------------------------------- | --------------------------------------------------------------- | +| #142 | `docs/positioning.md` | Close | +| #143 | `docs/glossary.md` | Close | +| #144 | `docs/pmf-decision-rubric.md` | Close | +| #145 | Root quickstart in `packages/flatbread/README.md` | Close | +| #146 | README/command guidance updates | Close | +| #147 | Relation-first traceability docs | Close | +| #148 | ID normalization helpers/tests | Close | +| #149 | Missing-reference validation/tests | Close | +| #150 | Duplicate-ID diagnostics/tests | Close | +| #151 | Cardinality docs/tests | Close | +| #152 | Validation snapshot fixtures | Close | +| #153 | Generated content-model types | Close | +| #154 | Prototype generated TypeScript read API | Close as prototype; iterate follow-ups | +| #155 | Read-interface docs | Close | +| #156 | Narrowed core type surfaces | Close; iterate follow-ups | +| #157 | `docs/local-dev-loop.md` design | Close design slice; implementation remains follow-up | +| #158 | Edit/query demo docs/scripts | Close | +| #159 | JSON export API/docs/tests | Close API slice; CLI remains follow-up | +| #160 | CSV export API/docs/tests | Close API slice; CLI remains follow-up | +| #161 | `docs/data-ownership.md` | Close | +| #162 | Starter benchmark report | Close; network-cold benchmark remains follow-up | +| #163 | TypeScript safety report | Close; external tests remain follow-up | +| #164 | Export trust report | Close product self-review; external interviews remain follow-up | +| #165 | This roadmap | Close after maintainer review | +| #166 | Already merged separately | Excluded | +| #167 | Effort Graph wire-up report/fixtures | Close | +| #168 | Adversarial schema report/fixtures | Close | +| #169 | Artifact retrieval benchmark | Close; token/multi-session benchmark remains follow-up | + +Human maintainers still need to confirm, split, or close issues in GitHub +because this environment cannot mutate issues directly. diff --git a/examples/content/README.md b/examples/content/README.md index 7507ebdf..7087ad23 100644 --- a/examples/content/README.md +++ b/examples/content/README.md @@ -1,5 +1,23 @@ # Example content -This is a central content store including various directories of content used for example integrations with Flatbread. This content is also used in our internal testing. +Central store for markdown and YAML used by **`examples/nextjs`** and other integrations. To avoid drift, **`examples/nextjs`** uses a symlink: **`examples/nextjs/content` → `../content`** (this directory). -To keep things DRY and easier to maintain, we've pointed or symlinked all content uses to this set. +## Layout for the primary onboarding story (posts · authors · tags) + +```text +markdown/posts/ # Post collection — frontmatter: id, title, authors (ids), tags (string list), … +markdown/authors/ # Author collection — referenced from posts via Flatbread `refs` +yaml/ # Extra YAML-backed samples (e.g. YamlAuthor); secondary to markdown onboarding +``` + +| Piece | Backing in this example | +| --- | --- | +| **Posts** | One file per post under `markdown/posts/` | +| **Authors** | One file per author under `markdown/authors/` | +| **Tags** | `tags:` list **in each post file’s frontmatter** (facet on **Post**). No separate `markdown/tags/` tree unless you introduce a **`Tag` collection** yourself. | + +- **Relations:** **`authors`** in post frontmatter lists **author ids** that match **`id`** in author files. Flatbread resolves them through **`refs: { authors: 'Author' }`** in **`flatbread.config.js`** relative to **`examples/nextjs`**. + +- **Tags:** lists like **`tags: [cats, science]`** in post frontmatter are **string facets** on each **`Post`** (arrays of scalars through the schema). That is **not** the same as a **`refs`-backed `Tag` collection**; normalized tag files require an extra **`Tag`** collection and explicit **`refs`** in config. + +Canonical commands and the end-to-end traceability story (same relation model: **files → config → GraphQL / codegen**) live in the [Flatbread README quickstart](https://github.com/FlatbreadLabs/flatbread/blob/main/packages/flatbread/README.md#quickstart-posts-authors-and-tags), including the **§ Traceability** walkthrough tied to the [glossary](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md). diff --git a/examples/content/markdown/authors/alex.md b/examples/content/markdown/authors/alex.md index 9310b2ca..228148ac 100644 --- a/examples/content/markdown/authors/alex.md +++ b/examples/content/markdown/authors/alex.md @@ -8,7 +8,7 @@ enjoys: - buying plants optimistically - researching why plants died - apologizing to houseplants -friend: tony +friend: 2a3e image: eva.svg # placeholder until we get alex.svg date_joined: 2023-08-12T14:30:00.000Z pronouns: they/them diff --git a/examples/content/markdown/posts/food/perfect-toast.md b/examples/content/markdown/posts/food/perfect-toast.md index 559de3dd..bccad1ee 100644 --- a/examples/content/markdown/posts/food/perfect-toast.md +++ b/examples/content/markdown/posts/food/perfect-toast.md @@ -2,8 +2,8 @@ id: toast-manifesto-2024 title: 'A Manifesto on the Perfect Toast: An Engineering Approach' authors: - - daes - - eva + - ab2c + - 40s3 rating: 88 category: food precision_level: unnecessary diff --git a/examples/content/markdown/posts/gaming/speedrun-disasters.md b/examples/content/markdown/posts/gaming/speedrun-disasters.md index 62d848b7..fc83eaf3 100644 --- a/examples/content/markdown/posts/gaming/speedrun-disasters.md +++ b/examples/content/markdown/posts/gaming/speedrun-disasters.md @@ -2,8 +2,8 @@ id: gaming-fails-2024 title: 'When Speedruns Go Spectacularly Wrong' authors: - - ushi - - yoshi + - 1111 + - r3c6 rating: 92 category: gaming difficulty: legendary diff --git a/examples/content/markdown/posts/soup.md b/examples/content/markdown/posts/soup.md index 306e31c9..caec3c87 100644 --- a/examples/content/markdown/posts/soup.md +++ b/examples/content/markdown/posts/soup.md @@ -2,8 +2,8 @@ id: jksfd4-234fdh-5345fj-3455-09836 title: 'The Great Soup Tier List: A Comprehensive Ranking' authors: - - daes - - caffeine-researcher + - ab2c + - 2a3e rating: 96 category: food research_duration: '2 winters' diff --git a/examples/content/markdown/posts/tech/debugging-at-3am.md b/examples/content/markdown/posts/tech/debugging-at-3am.md index 1559f416..b6819430 100644 --- a/examples/content/markdown/posts/tech/debugging-at-3am.md +++ b/examples/content/markdown/posts/tech/debugging-at-3am.md @@ -2,7 +2,7 @@ id: debugging-adventures-3am title: 'Debugging at 3 AM: A Horror Story' authors: - - tony + - 2a3e rating: 95 category: tech time_spent: '4.5 hours' diff --git a/examples/content/yaml/authors/dr-caffeine.yml b/examples/content/yaml/authors/dr-caffeine.yml index 2a22d62e..aacd0b18 100644 --- a/examples/content/yaml/authors/dr-caffeine.yml +++ b/examples/content/yaml/authors/dr-caffeine.yml @@ -7,7 +7,7 @@ enjoys: - "data visualization" - "converting people to the ways of good coffee" - "late-night research sessions" -friend: "tony" +friend: "2a3e" date_joined: "2023-01-15T08:30:00.000Z" pronouns: "she/her" location: "Portland, OR (where else?)" diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md index 24e25b08..ccb8651e 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -1,130 +1,158 @@ # Flatbread Next.js Example with TypeScript Codegen -This example demonstrates how to use Flatbread with Next.js and automatic TypeScript type generation. +This example is the repo’s **default first success path**: **relational Git-backed markdown** ( **`Post`** ↔ **`Author`** via `refs`; **`tags`** as string arrays on posts) compiled into a typed shape. **GraphQL plus codegen** are one read path baked into this demo, and the generated TypeScript read API gives simple app reads a collection-shaped interface over the same typed model; see [Choosing a read interface](../../packages/flatbread/README.md#choosing-a-read-interface). -## 🚀 Quick Start +**Note:** `flatbread.config.js` here also declares **PostCategory**, **OverrideTest**, **YamlAuthor**, etc. for integration tests. Treat those as **secondary**; the onboarding narrative is **posts + authors + tags** on the **`Post`** row. -1. **Install dependencies:** - ```bash - npm install - ``` +## Quick start (from monorepo root) + +1. **Install and build packages** (excluding examples): -2. **Generate TypeScript types from GraphQL schema:** ```bash - npx flatbread codegen --documents "src/queries/**/*.graphql" --verbose + pnpm install + pnpm build ``` -3. **Start the Flatbread server:** +2. **Enter this example:** + ```bash - npx flatbread dev + cd examples/nextjs ``` -4. **In another terminal, start the Next.js development server:** +3. **Generate TypeScript types once** (paths and globs come from `flatbread.config.js`; output: `generated/graphql.ts`): + ```bash - npm run dev + pnpm exec flatbread codegen --verbose ``` -5. **Open your browser to** `http://localhost:3000` + Add or edit **`.graphql`** files under `queries/` (or globs in config), then rerun codegen so **`tags`**, **`authors`**, and other fields stay in sync. Codegen also emits the prototype **generated TypeScript read API** in `generated/graphql.ts`; see `lib/read.ts` for the posts/authors/tags example that calls `createFlatbreadReadApi()`, and see [Choosing a read interface](../../packages/flatbread/README.md#choosing-a-read-interface) for when to use each read path. -## 📁 Project Structure +4. **Serve the GraphQL read interface alongside Next** (**there is no `flatbread dev`** — use **`flatbread start`**): -- `flatbread.config.js` - Flatbread configuration -- `src/generated/graphql.ts` - Auto-generated TypeScript types -- `src/queries/posts.graphql` - GraphQL queries for type generation -- `src/lib/graphql.ts` - GraphQL client utilities -- `src/components/` - React components using generated types -- `app/page.tsx` - Main page displaying content + - **Recommended / headless-safe:** `pnpm exec flatbread start -- next dev --turbopack`. + - **Package shortcut:** `pnpm dev` — currently passes `--https` for local convenience, but the Flatbread GraphQL endpoint remains documented as HTTP on `5057`. -## 🏗️ Generated Types +5. Open **[http://localhost:3000](http://localhost:3000)** for the app. Flatbread defaults to **`http://localhost:5057/graphql`** (not the Next port). -The example uses `@flatbread/codegen` to automatically generate TypeScript types from your Flatbread GraphQL schema. Types are generated based on: +### Scripts in this package -1. **GraphQL Schema** - Generated from your Flatbread configuration -2. **GraphQL Documents** - Queries defined in `src/queries/` +| Script | Purpose | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `pnpm dev` | **`flatbread start`** + Next dev (HTTPS). GraphQL on **5057**, Next on **3000**. | +| `pnpm build` | **`flatbread start`** wrapping **`next build`** so schema/codegen paths resolve during build. | +| `pnpm start` | **`next start` only** — production Next; does **not** run Flatbread unless you arrange it. | +| `pnpm run codegen` | **Watch-only:** `flatbread codegen --watch` — regenerate types when config/content/documents change. | +| `pnpm run demo:watch-query` | Watch `example-post.md` and print updated posts/authors/tags query results. | +| `pnpm run demo:edit` / `demo:restore` | Edit and restore the watched post title for the demo loop. | -### Regenerating Types +### Watch-only codegen -When you change your Flatbread configuration or GraphQL queries, regenerate types: +For iterative work, run the watcher in a second terminal: ```bash -npx flatbread codegen --verbose +pnpm run codegen ``` -### Watching for Changes +For the full loader/schema/codegen/framework boundary contract, see +[`docs/local-dev-loop.md`](../../docs/local-dev-loop.md). In short: codegen can +watch content/config/document files, but the running GraphQL server still needs +a restart for schema or content changes today. -For development, you can watch for changes and auto-regenerate: +To see a Markdown/YAML edit/query loop without manually restarting a server, run +the focused demo watcher: ```bash -npx flatbread codegen --watch --verbose +pnpm run demo:watch-query ``` -## 🎯 Features Demonstrated +Then run `pnpm run demo:edit`; the terminal prints updated Markdown +posts/authors/tags and YAML author query results. Full walkthrough: +[`docs/edit-file-see-query-update-demo.md`](../../docs/edit-file-see-query-update-demo.md). -- ✅ **Type-Safe GraphQL Queries** - Using generated TypeScript types -- ✅ **Intelligent Caching** - Avoids regeneration when config unchanged -- ✅ **Component Composition** - React components with proper typing -- ✅ **Server-Side Rendering** - Next.js App Router with async data fetching -- ✅ **Error Handling** - Graceful fallbacks for data loading errors +## Content path -## 📝 GraphQL Queries +Markdown and YAML for this demo live under **`examples/content`**; this package uses a **`content` → `../content`** symlink so config paths stay `content/markdown/...`. -Example queries in `src/queries/posts.graphql`: +- **Posts:** `examples/content/markdown/posts/` (`tags` in frontmatter → `[String]` on **`Post`** in the schema.) +- **Authors:** `examples/content/markdown/authors/` (referenced by id from **`Post`** **`authors`**.) -- `GetPostCategories` - Fetch all post categories with authors and images -- `GetAllPosts` - Fetch all posts with basic information -- `GetAuthors` - Fetch all authors with skills and images +Canonical layout, **backing files for tags** (facet on each post), **traceability** (same **relation model** from files through config to read interfaces and illustrative query JSON), and guidance on GraphQL versus the generated TypeScript read API are documented in the [Flatbread README quickstart](../../packages/flatbread/README.md#quickstart-posts-authors-and-tags), [Choosing a read interface](../../packages/flatbread/README.md#choosing-a-read-interface), and [glossary](../../docs/glossary.md). -## 🔧 Configuration +## Project structure -### Flatbread Config (`flatbread.config.js`) +- `app/` — routes and components (`page.tsx`, `post/[id]/`, etc.) +- `lib/graphql.ts` — GraphQL client helpers (default endpoint `http://localhost:5057/graphql`) +- `generated/graphql.ts` — generated TypeScript types and documents (`flatbread codegen`) +- `queries/*.graphql` — GraphQL documents included via `flatbread.config.js` +- `flatbread.config.js` — sources, transformers, collections, and codegen options +- `content` → `../content` — shared example content (symlink to `examples/content`) -Standard Flatbread configuration with content sources and transformers. +## Configuration snippets -### Codegen Config - -You can customize codegen behavior in your `flatbread.config.js`: +Codegen in `flatbread.config.js` matches the checked-in file — excerpt: ```javascript -export default defineConfig({ - // ... your existing config - codegen: { - enabled: true, - outputDir: './src/generated', - outputFile: 'graphql.ts', - documents: ['src/queries/**/*.graphql'], - watch: false, - cache: true, - }, -}); +codegen: { + enabled: true, + outputDir: './generated', + outputFile: 'graphql.ts', + documents: [ + './**/*.graphql', + './**/*.gql', + './components/**/*.graphql', + ], + // ... +}, ``` -## 🎨 Styling +## Regenerating types -This example uses Tailwind CSS for styling, similar to the SvelteKit example. The layout features: +After changing Flatbread config, content, or `.graphql` documents: -- **Split Pane Layout** - JSON output on left, rendered UI on right -- **PostCard Components** - Displays posts with authors, ratings, and content -- **Responsive Design** - Works on different screen sizes +```bash +pnpm exec flatbread codegen --verbose +``` -## 🚫 Troubleshooting +Force regeneration (clear cache): -### "No posts found" -Make sure the Flatbread server is running on `http://localhost:5057`: ```bash -npx flatbread dev +pnpm exec flatbread codegen --clear-cache --verbose ``` -### TypeScript Errors -Regenerate types if your schema changed: -```bash -npx flatbread codegen --clear-cache --verbose +## Generated TypeScript read API prototype + +The demo still keeps GraphQL available, but `flatbread codegen` now also emits typed helpers from the configured content model: + +- `createFlatbreadReadApi(execute)` — builds collection readers with generated default selections. +- `FlatbreadRecord<'Post'>` — typed record for a configured collection. +- `FlatbreadRelationTarget<'Post', 'authors'>` — typed relation result (`ReadonlyArray` for this example). + +`lib/read.ts` wires those helpers to this app's existing `graphqlFetch` client: + +```typescript +import { getPostsAuthorsAndTagsViaReadApi } from './lib/read'; + +const posts = await getPostsAuthorsAndTagsViaReadApi(); +const authorNames = posts[0]?.authors?.map((author) => author.name); +const tags = posts[0]?.tags; ``` -### Network Errors -Check that your GraphQL endpoint is accessible and CORS is configured properly. +That path queries **posts**, **authors**, and **tags** through the generated TypeScript API while GraphQL remains the underlying execution layer. The lower-level generated methods still accept an optional GraphQL selection string for experimentation, but the canonical example uses the generated default selection so the call site does not hand-write a GraphQL document. For custom selections, persisted operations, or direct GraphQL clients, use operation documents instead; the root [Choosing a read interface](../../packages/flatbread/README.md#choosing-a-read-interface) section is the canonical contract. + +## Troubleshooting + +### "No posts found" or network errors + +Ensure something is serving Flatbread at **`http://localhost:5057/graphql`** — typically by running **`pnpm dev`** or **`pnpm exec flatbread start -- next dev --turbopack`** from this directory, not `pnpm start` alone. + +### TypeScript errors after schema changes + +Run **`pnpm exec flatbread codegen --clear-cache --verbose`**. -## 📚 Learn More +## Learn more -- [Flatbread Documentation](https://github.com/FlatbreadLabs/flatbread) +- [Flatbread package README](../../packages/flatbread/README.md) — quickstart, install, **`flatbread start`**, and choosing GraphQL or the generated TypeScript read API +- [Glossary](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md) — collections, relations; GraphQL as one surface +- [Contributing / monorepo workflow](https://github.com/FlatbreadLabs/flatbread/blob/main/CONTRIBUTING.md) - [GraphQL Code Generator](https://www.the-guild.dev/graphql/codegen) -- [Next.js Documentation](https://nextjs.org/docs) \ No newline at end of file +- [Next.js Documentation](https://nextjs.org/docs) diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx index a2a114ad..381806ce 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -1,4 +1,5 @@ import { graphqlFetch, queries } from '../lib/graphql'; +import { getAuthorsViaReadApi, getPostsAuthorsAndTagsViaReadApi } from '../lib/read'; import type { PostCategory } from '../generated/graphql'; import BlogIndex from './components/BlogIndex'; import QueryPanel from './components/QueryPanel'; @@ -18,7 +19,32 @@ async function getData(): Promise { } export default async function Home() { - const data = await getData(); + const [data, readApiResults] = await Promise.all([ + getData(), + Promise.allSettled([ + getPostsAuthorsAndTagsViaReadApi(), + getAuthorsViaReadApi(), + ]), + ]); + const [readApiPostsResult, readApiAuthorsResult] = readApiResults; + const readApiError = + readApiPostsResult.status === 'rejected' || + readApiAuthorsResult.status === 'rejected' + ? { + posts: + readApiPostsResult.status === 'rejected' + ? String(readApiPostsResult.reason) + : undefined, + authors: + readApiAuthorsResult.status === 'rejected' + ? String(readApiAuthorsResult.reason) + : undefined, + } + : undefined; + const readApiPosts = + readApiPostsResult.status === 'fulfilled' ? readApiPostsResult.value : []; + const readApiAuthors = + readApiAuthorsResult.status === 'fulfilled' ? readApiAuthorsResult.value : []; return (
@@ -31,7 +57,7 @@ export default async function Home() { {/* Query Panel */}
diff --git a/examples/nextjs/generated/graphql.ts b/examples/nextjs/generated/graphql.ts index d45fdd70..e22a7495 100644 --- a/examples/nextjs/generated/graphql.ts +++ b/examples/nextjs/generated/graphql.ts @@ -8,6 +8,7 @@ export type MakeEmpty = export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; /** All built-in and custom scalars, mapped to their actual values */ export interface Scalars { + /** The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID. */ ID: { input: string; output: string; } /** The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text. */ String: { input: string; output: string; } @@ -397,32 +398,32 @@ export interface Query { export interface QueryAuthorArgs { - id?: InputMaybe; + id?: InputMaybe; } export interface QueryOverrideTestArgs { - id?: InputMaybe; + id?: InputMaybe; } export interface QueryPostArgs { - id?: InputMaybe; + id?: InputMaybe; } export interface QueryPostCategoryArgs { - id?: InputMaybe; + id?: InputMaybe; } export interface QueryPostCategoryBlobArgs { - id?: InputMaybe; + id?: InputMaybe; } export interface QueryYamlAuthorArgs { - id?: InputMaybe; + id?: InputMaybe; } @@ -561,7 +562,7 @@ export type GetAllPostsQueryVariables = Exact<{ [key: string]: never; }>; export type GetAllPostsQuery = { __typename?: 'Query', allPosts?: Array<{ __typename?: 'Post', id?: string | null, title?: string | null, _content?: { __typename?: 'Post__content', html?: string | null, excerpt?: string | null, timeToRead?: number | null } | null, authors?: Array<{ __typename?: 'Author', id?: string | null, name?: string | null } | null> | null } | null> | null }; export type GetPostByIdQueryVariables = Exact<{ - id: Scalars['String']['input']; + id: Scalars['ID']['input']; }>; @@ -581,6 +582,176 @@ export type PostsummaryFragment = { __typename?: 'Post', id?: string | null, tit export const PostsummaryFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Postsummary"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Post"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"_content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"html"}},{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"timeToRead"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const GetAllPostsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAllPosts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allPosts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"_content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"html"}},{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"timeToRead"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetPostByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPostById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"Post"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"_content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"html"}},{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"timeToRead"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"friend"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetPostByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPostById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"Post"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"_content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"html"}},{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"timeToRead"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"friend"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetPostCategoriesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPostCategories"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allPostCategories"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"sortBy"},"value":{"kind":"StringValue","value":"title","block":false}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"EnumValue","value":"DESC"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_collection"}},{"kind":"Field","name":{"kind":"Name","value":"_filename"}},{"kind":"Field","name":{"kind":"Name","value":"_slug"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"rating"}},{"kind":"Field","name":{"kind":"Name","value":"_content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"raw"}},{"kind":"Field","name":{"kind":"Name","value":"html"}},{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"timeToRead"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_slug"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"entity"}},{"kind":"Field","name":{"kind":"Name","value":"enjoys"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"srcset"}},{"kind":"Field","name":{"kind":"Name","value":"srcsetwebp"}},{"kind":"Field","name":{"kind":"Name","value":"srcsetavif"}},{"kind":"Field","name":{"kind":"Name","value":"placeholder"}},{"kind":"Field","name":{"kind":"Name","value":"aspectratio"}}]}},{"kind":"Field","name":{"kind":"Name","value":"friend"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"date_joined"}}]}},{"kind":"Field","name":{"kind":"Name","value":"date_joined"}},{"kind":"Field","name":{"kind":"Name","value":"skills"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sitting"}},{"kind":"Field","name":{"kind":"Name","value":"breathing"}},{"kind":"Field","name":{"kind":"Name","value":"liquid_consumption"}},{"kind":"Field","name":{"kind":"Name","value":"existence"}},{"kind":"Field","name":{"kind":"Name","value":"sports"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetAuthorsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAuthors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allAuthors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"entity"}},{"kind":"Field","name":{"kind":"Name","value":"enjoys"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"srcset"}},{"kind":"Field","name":{"kind":"Name","value":"srcsetwebp"}},{"kind":"Field","name":{"kind":"Name","value":"srcsetavif"}},{"kind":"Field","name":{"kind":"Name","value":"placeholder"}},{"kind":"Field","name":{"kind":"Name","value":"aspectratio"}}]}},{"kind":"Field","name":{"kind":"Name","value":"date_joined"}},{"kind":"Field","name":{"kind":"Name","value":"skills"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sitting"}},{"kind":"Field","name":{"kind":"Name","value":"breathing"}},{"kind":"Field","name":{"kind":"Name","value":"liquid_consumption"}},{"kind":"Field","name":{"kind":"Name","value":"existence"}},{"kind":"Field","name":{"kind":"Name","value":"sports"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const GetAuthorsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAuthors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allAuthors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"entity"}},{"kind":"Field","name":{"kind":"Name","value":"enjoys"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"srcset"}},{"kind":"Field","name":{"kind":"Name","value":"srcsetwebp"}},{"kind":"Field","name":{"kind":"Name","value":"srcsetavif"}},{"kind":"Field","name":{"kind":"Name","value":"placeholder"}},{"kind":"Field","name":{"kind":"Name","value":"aspectratio"}}]}},{"kind":"Field","name":{"kind":"Name","value":"date_joined"}},{"kind":"Field","name":{"kind":"Name","value":"skills"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sitting"}},{"kind":"Field","name":{"kind":"Name","value":"breathing"}},{"kind":"Field","name":{"kind":"Name","value":"liquid_consumption"}},{"kind":"Field","name":{"kind":"Name","value":"existence"}},{"kind":"Field","name":{"kind":"Name","value":"sports"}}]}}]}}]}}]} as unknown as DocumentNode; + +/* @flatbread/content-model-types:start */ +/** + * Flatbread content model types generated from flatbread.config.*. + * These describe configured collections and refs before any GraphQL operation documents are required. + */ +export type FlatbreadCollectionName = "Post" | "PostCategory" | "PostCategoryBlob" | "Author" | "YamlAuthor" | "OverrideTest"; + +export type FlatbreadRecordByCollection = { + "Post": Post; + "PostCategory": PostCategory; + "PostCategoryBlob": PostCategoryBlob; + "Author": Author; + "YamlAuthor": YamlAuthor; + "OverrideTest": OverrideTest; +}; + +export type FlatbreadRelationTargetByCollection = { + "Post": { + "authors": { target: "Author"; cardinality: "many"; }; + }; + "PostCategory": { + "authors": { target: "Author"; cardinality: "many"; }; + }; + "PostCategoryBlob": { + "authors": { target: "Author"; cardinality: "many"; }; + }; + "Author": { + "friend": { target: "Author"; cardinality: "one"; }; + }; + "YamlAuthor": { + "friend": { target: "YamlAuthor"; cardinality: "one"; }; + }; + "OverrideTest": {}; +}; + +export type FlatbreadRecord< + Collection extends FlatbreadCollectionName, +> = FlatbreadRecordByCollection[Collection]; + +export type FlatbreadRelationTarget< + Collection extends FlatbreadCollectionName, + Field extends keyof FlatbreadRelationTargetByCollection[Collection], +> = FlatbreadRecord< + Extract< + FlatbreadRelationTargetCollection, + FlatbreadCollectionName + > +> extends infer TargetRecord + ? FlatbreadRelationCardinality extends 'many' + ? ReadonlyArray | null + : TargetRecord | null + : never; + +export type FlatbreadRelationTargetCollection< + Collection extends FlatbreadCollectionName, + Field extends keyof FlatbreadRelationTargetByCollection[Collection], +> = FlatbreadRelationTargetByCollection[Collection][Field] extends { + target: infer Target; +} + ? Target + : never; + +export type FlatbreadRelationCardinality< + Collection extends FlatbreadCollectionName, + Field extends keyof FlatbreadRelationTargetByCollection[Collection], +> = FlatbreadRelationTargetByCollection[Collection][Field] extends { + cardinality: infer Cardinality; +} + ? Cardinality + : never; + +export type FlatbreadReadableCollectionName = "Post" | "PostCategory" | "PostCategoryBlob" | "Author" | "YamlAuthor" | "OverrideTest"; + +export type FlatbreadGraphQLExecutor = ( + source: string, + variables?: Record, +) => Promise; + +/** + * Experimental generated read API over the Flatbread content model. + * + * The API owns collection names, root query names, IDs, and result typing. The + * current prototype still accepts a GraphQL selection string for fields; invalid + * or drifting selections are runtime GraphQL errors, not type errors. + */ +export type FlatbreadReadApi = { + "Post": { + all(selection?: string): Promise>>>; + find(id: string | number, selection?: string): Promise> | null>; + }; + "PostCategory": { + all(selection?: string): Promise>>>; + find(id: string | number, selection?: string): Promise> | null>; + }; + "PostCategoryBlob": { + all(selection?: string): Promise>>>; + find(id: string | number, selection?: string): Promise> | null>; + }; + "Author": { + all(selection?: string): Promise>>>; + find(id: string | number, selection?: string): Promise> | null>; + }; + "YamlAuthor": { + all(selection?: string): Promise>>>; + find(id: string | number, selection?: string): Promise> | null>; + }; + "OverrideTest": { + all(selection?: string): Promise>>>; + find(id: string | number, selection?: string): Promise> | null>; + }; +}; + +const flatbreadReadApiQueries = { + "Post": { all: "allPosts", find: "Post", idType: "ID", selection: "_filename\n_path\n_slug\nid\ntitle\nauthors { _filename\n_path\n_slug\nid\nname\nentity\nbio\nenjoys\ndate_joined\npronouns\nlocation\nfavorite_technologies\nplant_murder_count\ncurrent_survivors\nplant_store_reputation\nfavorite_activities\ncertifications\n_collection }\nrating\n_content { raw\nhtml\nexcerpt\ntimeToRead }\ncategory\ntags\nresearch_duration\nsoups_tested\ntemperature_preference\nslurp_factor\ncontroversial_opinions\n_collection" }, + "PostCategory": { all: "allPostCategories", find: "PostCategory", idType: "ID", selection: "_filename\n_path\n_slug\ncategory\nslug\nid\ntitle\nauthors { _filename\n_path\n_slug\nid\nname\nentity\nbio\nenjoys\ndate_joined\npronouns\nlocation\nfavorite_technologies\nplant_murder_count\ncurrent_survivors\nplant_store_reputation\nfavorite_activities\ncertifications\n_collection }\nrating\n_content { raw\nhtml\nexcerpt\ntimeToRead }\nprecision_level\nbread_types_tested\nbutter_temperature\ntoast_settings { darkness\ncrunch_factor\nbutter_distribution }\ndifficulty\nattempts\nbugs_encountered\nplants_murdered\nsuccess_rate\ncurrent_survivors\nwatering_schedule\nplant_types_attempted { succulents\nherbs\nsnake_plant\nbamboo }\ntime_spent\ncoffee_consumed\nsanity_level\nbug_severity\ndebugging_attempts { rubber_duck_debugging\nstack_overflow_diving\nprayer_to_tech_gods\nritual_coffee_sacrifice }\n_collection" }, + "PostCategoryBlob": { all: "allPostCategoryBlobs", find: "PostCategoryBlob", idType: "ID", selection: "_filename\n_path\n_slug\nid\ntitle\nauthors { _filename\n_path\n_slug\nid\nname\nentity\nbio\nenjoys\ndate_joined\npronouns\nlocation\nfavorite_technologies\nplant_murder_count\ncurrent_survivors\nplant_store_reputation\nfavorite_activities\ncertifications\n_collection }\nrating\n_content { raw\nhtml\nexcerpt\ntimeToRead }\ncategory\nprecision_level\nbread_types_tested\nbutter_temperature\ntoast_settings { darkness\ncrunch_factor\nbutter_distribution }\ndifficulty\nattempts\nbugs_encountered\nplants_murdered\nsuccess_rate\ncurrent_survivors\nwatering_schedule\nplant_types_attempted { succulents\nherbs\nsnake_plant\nbamboo }\ntime_spent\ncoffee_consumed\nsanity_level\nbug_severity\ndebugging_attempts { rubber_duck_debugging\nstack_overflow_diving\nprayer_to_tech_gods\nritual_coffee_sacrifice }\n_collection" }, + "Author": { all: "allAuthors", find: "Author", idType: "ID", selection: "image { srcset\nsrcsetwebp\nsrcsetavif\nplaceholder\naspectratio }\n_filename\n_path\n_slug\nid\nname\nentity\nbio\nenjoys\nfriend { _filename\n_path\n_slug\nid\nname\nentity\nbio\nenjoys\ndate_joined\npronouns\nlocation\nfavorite_technologies\nplant_murder_count\ncurrent_survivors\nplant_store_reputation\nfavorite_activities\ncertifications\n_collection }\ndate_joined\npronouns\nlocation\nfavorite_technologies\nskills { sitting\nbreathing\nliquid_consumption\nexistence\nsports\nplant_care\ndebugging\noptimistic_plant_purchasing\nkeyboard_walking\nmeeting_interruption }\nplant_murder_count\ncurrent_survivors\nplant_store_reputation\n_content { raw\nhtml\nexcerpt\ntimeToRead }\nfavorite_activities\ncertifications\n_collection" }, + "YamlAuthor": { all: "allYamlAuthors", find: "YamlAuthor", idType: "ID", selection: "_filename\n_path\n_slug\nid\nname\nentity\nbio\nenjoys\nfriend { _filename\n_path\n_slug\nid\nname\nentity\nbio\nenjoys\ndate_joined\npronouns\nlocation\ncertifications\neducation\nfavorite_technologies\nresearch_focus\ncurrent_projects\ncoffee_consumption_daily\nfavorite_brewing_methods\n_collection }\ndate_joined\npronouns\nlocation\ncertifications\neducation\nfavorite_technologies\nresearch_focus\nskills { sitting\nbreathing\nliquid_consumption\nexistence\nsports\ncoffee_brewing\ndata_analysis\nspreadsheet_mastery\ncat_pat }\ncurrent_projects\ncoffee_consumption_daily\nfavorite_brewing_methods\n_content { html\nexcerpt\ntimeToRead }\n_collection" }, + "OverrideTest": { all: "allOverrideTests", find: "OverrideTest", idType: "ID", selection: "deeply { nested }\narray\narray2 { obj }\n_filename\n_path\n_slug\nid\ntitle\n_content { raw\nhtml\nexcerpt\ntimeToRead }\n_collection" } +} as const; + +export function createFlatbreadReadApi( + execute: FlatbreadGraphQLExecutor, +): FlatbreadReadApi { + return Object.fromEntries( + Object.entries(flatbreadReadApiQueries).map(([collection, queries]) => [ + collection, + { + all: async (selection = queries.selection) => { + const readSelection = normalizeFlatbreadReadSelection(selection); + const operationName = flatbreadReadApiOperationName(collection, 'All'); + const data = await execute>>( + `query ${operationName} { ${queries.all} { ${readSelection} } }`, + ); + return data[queries.all] ?? []; + }, + find: async (id: string | number, selection = queries.selection) => { + const readSelection = normalizeFlatbreadReadSelection(selection); + const operationName = flatbreadReadApiOperationName(collection, 'Find'); + const data = await execute>( + `query ${operationName}($id: ${queries.idType}) { ${queries.find}(id: $id) { ${readSelection} } }`, + { id }, + ); + return data[queries.find] ?? null; + }, + }, + ]), + ) as FlatbreadReadApi; +} + +function normalizeFlatbreadReadSelection(selection: string): string { + const normalized = selection.trim(); + if (!normalized) { + throw new Error('Flatbread read API selection must not be empty.'); + } + return normalized; +} + +function flatbreadReadApiOperationName( + collection: string, + action: string, +): string { + const safeCollection = collection.replace(/[^A-Za-z0-9_]/g, '_'); + const suffix = safeCollection && !/^\d/.test(safeCollection) + ? safeCollection + : `_${safeCollection || 'Collection'}`; + return `FlatbreadRead_${suffix}_${action}`; +} +/* @flatbread/content-model-types:end */ diff --git a/examples/nextjs/lib/read.ts b/examples/nextjs/lib/read.ts new file mode 100644 index 00000000..8f4ddb5e --- /dev/null +++ b/examples/nextjs/lib/read.ts @@ -0,0 +1,55 @@ +import { + createFlatbreadReadApi, + type FlatbreadRecord, + type FlatbreadRelationCardinality, + type FlatbreadRelationTarget, +} from '../generated/graphql'; +import { graphqlFetch } from './graphql'; + +export type PostAuthorsRelation = FlatbreadRelationTarget<'Post', 'authors'>; +export type PostAuthorsCardinality = FlatbreadRelationCardinality< + 'Post', + 'authors' +>; + +export type PostsAuthorsTagsReadItem = Partial< + Pick, 'id' | 'tags' | 'title'> +> & { + authors?: PostAuthorsRelation; +}; + +const postAuthorsCardinality: PostAuthorsCardinality = 'many'; + +export const flatbreadRead = createFlatbreadReadApi( + async (source: string, variables?: Record) => + graphqlFetch(source, variables) +); + +/** + * Generated TypeScript read API example for the canonical onboarding model. + * + * The default GraphQL selection is generated inside `createFlatbreadReadApi`, + * so this call site does not hand-write a GraphQL document or selection string. + */ +export async function getPostsAuthorsAndTagsViaReadApi(): Promise< + ReadonlyArray +> { + const posts = await flatbreadRead.Post.all(); + + if (postAuthorsCardinality !== 'many') { + throw new Error('Expected Post.authors to be generated as a many relation.'); + } + + return posts.map((post) => ({ + authors: post?.authors, + id: post?.id, + tags: post?.tags, + title: post?.title, + })); +} + +export async function getAuthorsViaReadApi(): Promise< + ReadonlyArray>> +> { + return flatbreadRead.Author.all(); +} diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index bfb9d9ea..1fad3030 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -5,6 +5,9 @@ "scripts": { "dev": "flatbread start --https -- next dev --turbopack", "codegen": "flatbread codegen --watch", + "demo:edit": "node scripts/demo-edit.mjs", + "demo:restore": "node scripts/demo-restore.mjs", + "demo:watch-query": "node scripts/watch-content-query.mjs", "build": "flatbread start -- next build", "start": "next start", "lint": "next lint" diff --git a/examples/nextjs/queries/posts.graphql b/examples/nextjs/queries/posts.graphql index 697113a2..39794a07 100644 --- a/examples/nextjs/queries/posts.graphql +++ b/examples/nextjs/queries/posts.graphql @@ -16,7 +16,7 @@ query GetAllPosts { } } -query GetPostById($id: String!) { +query GetPostById($id: ID!) { Post(id: $id) { id title diff --git a/examples/nextjs/scripts/demo-edit.mjs b/examples/nextjs/scripts/demo-edit.mjs new file mode 100644 index 00000000..805127ca --- /dev/null +++ b/examples/nextjs/scripts/demo-edit.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from 'node:fs/promises'; + +const postFile = new URL('../content/markdown/posts/example-post.md', import.meta.url); +const yamlAuthorFile = new URL('../content/yaml/authors/dr-caffeine.yml', import.meta.url); +const original = "title: 'The Art of Measuring Cats in Fruit Units'"; +const edited = "title: 'The Art of Measuring Cats in Fruit Units — live edit'"; +const originalYamlName = 'name: "Dr. Maya Espresso"'; +const editedYamlName = 'name: "Dr. Maya Espresso — live edit"'; +const originalYamlFriend = 'friend: "2a3e"'; +const editedYamlFriend = 'friend: "40s3"'; + +const postText = await readFile(postFile, 'utf-8'); +await writeFile(postFile, postText.replace(original, edited)); + +const yamlText = await readFile(yamlAuthorFile, 'utf-8'); +await writeFile( + yamlAuthorFile, + yamlText + .replace(originalYamlName, editedYamlName) + .replace(originalYamlFriend, editedYamlFriend) +); + +console.log('Edited Markdown post title plus YAML author name and friend ref for the Flatbread watch demo.'); diff --git a/examples/nextjs/scripts/demo-restore.mjs b/examples/nextjs/scripts/demo-restore.mjs new file mode 100644 index 00000000..a6ed231c --- /dev/null +++ b/examples/nextjs/scripts/demo-restore.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from 'node:fs/promises'; + +const postFile = new URL('../content/markdown/posts/example-post.md', import.meta.url); +const yamlAuthorFile = new URL('../content/yaml/authors/dr-caffeine.yml', import.meta.url); +const original = "title: 'The Art of Measuring Cats in Fruit Units'"; +const edited = "title: 'The Art of Measuring Cats in Fruit Units — live edit'"; +const originalYamlName = 'name: "Dr. Maya Espresso"'; +const editedYamlName = 'name: "Dr. Maya Espresso — live edit"'; +const originalYamlFriend = 'friend: "2a3e"'; +const editedYamlFriend = 'friend: "40s3"'; + +const postText = await readFile(postFile, 'utf-8'); +await writeFile(postFile, postText.replace(edited, original)); + +const yamlText = await readFile(yamlAuthorFile, 'utf-8'); +await writeFile( + yamlAuthorFile, + yamlText + .replace(editedYamlName, originalYamlName) + .replace(editedYamlFriend, originalYamlFriend) +); + +console.log('Restored Markdown post title plus YAML author name and friend ref after the Flatbread watch demo.'); diff --git a/examples/nextjs/scripts/watch-content-query.mjs b/examples/nextjs/scripts/watch-content-query.mjs new file mode 100644 index 00000000..cc4d8f29 --- /dev/null +++ b/examples/nextjs/scripts/watch-content-query.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +import { watch } from 'node:fs'; +import { resolve } from 'node:path'; +import { loadConfig } from '@flatbread/config'; +import { FlatbreadProvider } from '@flatbread/core'; + +const cwd = process.cwd(); +const watchedFiles = [ + resolve(cwd, 'content/markdown/posts/example-post.md'), + resolve(cwd, 'content/yaml/authors/dr-caffeine.yml'), +]; + +const query = ` + query DemoPost { + allPosts(filter: { id: { eq: "sdfsdf-23423-sdfsd-23444-dfghf" } }) { + id + title + tags + authors { + id + name + } + } + allYamlAuthors(filter: { id: { eq: "caffeine-researcher" } }) { + id + name + friend { + id + name + } + } + } +`; + +let renderInFlight = false; +let renderAgain = false; + +async function loadFreshProvider() { + const result = await loadConfig({ cwd }); + if (!result.config) { + throw new Error('Flatbread config did not load.'); + } + + // generateSchema caches by config, but this demo intentionally rebuilds the + // content graph on every file event to show edit -> query update without a + // server restart. + const config = { + ...result.config, + content: result.config.content.map((entry) => ({ + ...entry, + __demoCacheBust: Date.now(), + })), + }; + + return new FlatbreadProvider(config); +} + +async function render() { + if (renderInFlight) { + renderAgain = true; + return; + } + + renderInFlight = true; + try { + const provider = await loadFreshProvider(); + const response = await provider.query({ source: query }); + const payload = { + renderedAt: new Date().toISOString(), + data: response.data, + errors: response.errors?.map((error) => error.message), + }; + + console.log(JSON.stringify(payload, null, 2)); + } finally { + renderInFlight = false; + if (renderAgain) { + renderAgain = false; + await render(); + } + } +} + +console.log(`Watching ${watchedFiles.join(', ')}`); +await render(); + +for (const watchedFile of watchedFiles) { + watch(watchedFile, { persistent: true }, () => { + setTimeout(() => { + render().catch((error) => { + console.error(error); + process.exitCode = 1; + }); + }, 100); + }); +} diff --git a/flatbread-flow-pmf-audit.md b/flatbread-flow-pmf-audit.md index a5d88695..7ed8438d 100644 --- a/flatbread-flow-pmf-audit.md +++ b/flatbread-flow-pmf-audit.md @@ -2,6 +2,8 @@ Generated from the DAG task runner audit on May 7, 2026. +**Buyer-facing comparison rubric** (SQLite, CMS, Contentlayer-like, agent-artifact workflows; issue #144 acceptance-style criteria): [docs/pmf-decision-rubric.md](./docs/pmf-decision-rubric.md). + Canvas: `file:///Users/tonyketcham/.cursor/projects/Users-tonyketcham-Code-Github-personal-flatbread/canvases/dag-flatbread-pmf-audit.canvas.tsx` ## Executive Summary diff --git a/packages/codegen/README.md b/packages/codegen/README.md index 7e976ad0..c3cee760 100644 --- a/packages/codegen/README.md +++ b/packages/codegen/README.md @@ -1,6 +1,8 @@ # @flatbread/codegen 🏗️ -> Automatic TypeScript type generation for Flatbread GraphQL schemas +> TypeScript generation for Flatbread read interfaces + +Flatbread treats repo files as **relational, Git-tracked content** for TypeScript apps; GraphQL is one **read interface** over the typed model Flatbread derives from flat files and config. Codegen keeps GraphQL operations typed and emits model-derived TypeScript helpers for collection-shaped reads. ## 💾 Install @@ -12,7 +14,9 @@ pnpm add @flatbread/codegen ## 🎯 Overview -This package automatically generates TypeScript types from your Flatbread GraphQL schema, providing type safety for your GraphQL operations. It uses [GraphQL Code Generator](https://www.the-guild.dev/graphql/codegen) under the hood with intelligent caching to avoid unnecessary regeneration. +This package generates TypeScript from the Flatbread model so apps can read typed content through the interface that fits the call site. It uses [GraphQL Code Generator](https://www.the-guild.dev/graphql/codegen) under the hood for schema and operation types, and also emits a prototype generated TypeScript read API derived from your configured collections, fields, and refs. + +For the canonical posts/authors/tags walkthrough and the contract for choosing GraphQL versus the generated TypeScript read API, see the canonical [Quickstart](../flatbread/README.md#quickstart-posts-authors-and-tags) and [Choosing a read interface](../flatbread/README.md#choosing-a-read-interface). ## 👩‍🍳 Basic Usage @@ -33,7 +37,7 @@ export default defineConfig({ // Add codegen configuration codegen: { enabled: true, - outputDir: './src/generated', + outputDir: './generated', outputFile: 'graphql.ts', plugins: ['typescript', 'typescript-operations', 'typed-document-node'], }, @@ -46,16 +50,18 @@ export default defineConfig({ ```bash # Generate types once -npx flatbread codegen +pnpm exec flatbread codegen # Watch for changes and regenerate -npx flatbread codegen --watch +pnpm exec flatbread codegen --watch # Force regeneration (clear cache) -npx flatbread codegen --clear-cache +pnpm exec flatbread codegen --clear-cache ``` -### 4. Use generated types in your application: +### 4. Use the generated output in your application: + +Use **GraphQL operations** when you want explicit documents, custom selections, GraphQL clients, persisted operations, or direct access to the GraphQL endpoint: ```ts import type { Post, GetPostsQuery } from './generated/graphql'; @@ -73,9 +79,24 @@ const posts: Post[] = await request(` `); ``` -## 👀 Watch Mode +Use the prototype **generated TypeScript read API** when you want collection-shaped helpers for common reads from the configured content model. In the canonical Next.js example, `createFlatbreadReadApi()` reads posts, authors, and tags with a generated default selection while executing through the GraphQL layer: -The `--watch` flag enables automatic regeneration of TypeScript types whenever your source files change. This is particularly useful during development to keep your types in sync with your content and schema changes. +```ts +import { createFlatbreadReadApi } from './generated/graphql'; +import { graphqlFetch } from './lib/graphql'; + +const read = createFlatbreadReadApi( + async (source: string, variables?: Record) => + graphqlFetch(source, variables) +); +const posts = await read.Post.all(); +const authorNames = posts[0]?.authors?.map((author) => author.name); +const tags = posts[0]?.tags; +``` + +## 👀 Watch Mode (watch-only) + +The `--watch` flag enables automatic regeneration while the process stays running—**watch-only**; use one-shot `flatbread codegen` when you need a single generation (for example in CI). ### How Watch Mode Works @@ -99,8 +120,8 @@ npx flatbread codegen --watch --verbose 🥯 Flatbread TypeScript Code Generator Generating GraphQL schema... 🔍 Watching for changes... -Watching patterns: flatbread.config.*, content/posts/**/*.{md,mdx,markdown}, src/**/*.graphql -✓ Generated TypeScript types: /path/to/src/generated/graphql.ts +Watching patterns: flatbread.config.*, content/**/*.{md,mdx,markdown,yml,yaml}, **/*.graphql +✓ Generated TypeScript types: /path/to/generated/graphql.ts 👀 Ready for changes 📝 File changed: content/posts/new-article.md @@ -126,7 +147,7 @@ export interface CodegenOptions { enabled?: boolean; // default: false // Output directory for generated types - outputDir?: string; // default: './src/generated' + outputDir?: string; // default: './generated' // Output filename for generated types outputFile?: string; // default: 'graphql.ts' @@ -171,7 +192,7 @@ export default defineConfig({ }, // Include GraphQL documents from your app - documents: ['./src/**/*.graphql', './src/**/*.gql'], + documents: ['./queries/**/*.graphql', './**/*.graphql'], // Custom GraphQL Code Generator configuration codegenConfig: { @@ -203,7 +224,7 @@ const schema = await generateSchema(configResult); // Generate TypeScript types const result = await generateTypes(schema, configResult.config, { enabled: true, - outputDir: './src/generated', + outputDir: './generated', outputFile: 'types.ts', }); @@ -226,12 +247,13 @@ Types are only regenerated when one of these changes. You can force regeneration ```bash # Clear cache and regenerate -npx flatbread codegen --clear-cache +pnpm exec flatbread codegen --clear-cache -# Disable caching entirely -npx flatbread codegen --no-cache +# Or set codegen.cache to false in flatbread.config.* for non-cached runs ``` +Watch mode (`--watch`) is **watch-only**: leave it running during development; use a one-shot `flatbread codegen` (without `--watch`) when you only need a single generation. + ## 🎛️ CLI Options ```bash @@ -323,7 +345,7 @@ import { GetPostsDocument, type GetPostsQuery } from './generated/graphql'; export async function getStaticProps() { const data = await request( - 'http://localhost:5050/graphql', + 'http://localhost:5057/graphql', GetPostsDocument ); @@ -343,7 +365,7 @@ import { GetPostsDocument, type GetPostsQuery } from './generated/graphql'; export async function load() { const data = await request( - 'http://localhost:5050/graphql', + 'http://localhost:5057/graphql', GetPostsDocument ); diff --git a/packages/codegen/src/__tests__/e2e.test.ts b/packages/codegen/src/__tests__/e2e.test.ts index 7a6924a3..c6e0a82b 100644 --- a/packages/codegen/src/__tests__/e2e.test.ts +++ b/packages/codegen/src/__tests__/e2e.test.ts @@ -8,6 +8,7 @@ import { clearCache } from '../cache.js'; import type { LoadedFlatbreadConfig } from '@flatbread/core'; import type { CodegenOptions } from '../types.js'; import { buildSchema } from 'graphql'; +import ts from 'typescript'; /** * End-to-end tests for the codegen package @@ -36,6 +37,7 @@ describe('codegen end-to-end', () => { publishedAt: Date metadata: JSON author: Author + tags: [Tag!]! } type Author { @@ -43,6 +45,11 @@ describe('codegen end-to-end', () => { name: String! email: String } + + type Tag { + id: String! + label: String! + } `); beforeEach(async () => { @@ -69,6 +76,22 @@ describe('codegen end-to-end', () => { { collection: 'Post', path: join(tempDir, 'content/posts'), + refs: { + author: 'Author', + tags: 'Tag', + }, + }, + { + collection: 'Author', + path: join(tempDir, 'content/authors'), + }, + { + collection: 'Tag', + path: join(tempDir, 'content/tags'), + }, + { + collection: 'Draft', + path: join(tempDir, 'content/drafts'), }, ], fieldNameTransform: (field: string) => field, @@ -79,6 +102,9 @@ describe('codegen end-to-end', () => { // Create content directory structure await fs.mkdir(join(tempDir, 'content/posts'), { recursive: true }); + await fs.mkdir(join(tempDir, 'content/authors'), { recursive: true }); + await fs.mkdir(join(tempDir, 'content/tags'), { recursive: true }); + await fs.mkdir(join(tempDir, 'content/drafts'), { recursive: true }); }); afterEach(async () => { @@ -97,7 +123,7 @@ describe('codegen end-to-end', () => { outputDir: testOutputDir, outputFile: 'graphql.ts', plugins: ['typescript'], - cache: false, // Disable cache for predictable testing + cache: true, }; const result = await generateTypes(testSchema, mockConfig, options); @@ -118,6 +144,85 @@ describe('codegen end-to-end', () => { expect(content).toContain('Post'); expect(content).toContain('Author'); expect(content).toContain('Query'); + expect(content).toContain( + 'export type FlatbreadCollectionName = "Post" | "Author" | "Tag" | "Draft"' + ); + expect(content).toContain('export type FlatbreadRecordByCollection'); + expect(content).toContain('"Post": Post;'); + expect(content).toContain('"Author": Author;'); + expect(content).toContain('"Tag": Tag;'); + expect(content).toContain('"Draft": Record;'); + expect(content).toContain( + 'export type FlatbreadRelationTargetByCollection' + ); + expect(content).toContain( + '"author": { target: "Author"; cardinality: "one"; };' + ); + expect(content).toContain( + '"tags": { target: "Tag"; cardinality: "many"; };' + ); + expect(content).toContain('FlatbreadRelationTargetCollection'); + expect(content).toContain('FlatbreadRelationCardinality'); + expect(content).toContain('export function createFlatbreadReadApi'); + expect(content).toContain('"Post": { all: "posts", find: "post"'); + expect(content).toContain('author { id'); + expect(content).toContain('@flatbread/content-model-types:start'); + + const usageFile = join(testOutputDir, 'usage.ts'); + await fs.writeFile( + usageFile, + ` + import type { + FlatbreadCollectionName, + FlatbreadRecord, + FlatbreadRelationTarget, + FlatbreadRelationCardinality, + FlatbreadRelationTargetCollection, + } from './graphql'; + import { createFlatbreadReadApi } from './graphql'; + + const collection: FlatbreadCollectionName = 'Post'; + type PostRecord = FlatbreadRecord<'Post'>; + type RelatedAuthor = FlatbreadRelationTarget<'Post', 'author'>; + type RelatedTags = FlatbreadRelationTarget<'Post', 'tags'>; + const relation: FlatbreadRelationTargetCollection<'Post', 'author'> = 'Author'; + const cardinality: FlatbreadRelationCardinality<'Post', 'author'> = 'one'; + const tagCardinality: FlatbreadRelationCardinality<'Post', 'tags'> = 'many'; + const read = createFlatbreadReadApi(async () => ({}) as never); + + async function readPosts() { + const posts = await read.Post.all(); + const post: Partial | undefined = posts[0]; + const author: RelatedAuthor | undefined = post?.author ?? undefined; + return { post, author }; + } + + export type Assertions = { + collection: typeof collection; + post: PostRecord; + relatedAuthor: RelatedAuthor; + relatedTags: RelatedTags; + relationCollection: typeof relation; + cardinality: typeof cardinality; + tagCardinality: typeof tagCardinality; + readPosts: Awaited>; + }; + ` + ); + + expectTypeScriptToCompile([generatedFile, usageFile]); + const beforeCacheMtime = (await fs.stat(generatedFile)).mtimeMs; + + const secondResult = await generateTypes(testSchema, mockConfig, options); + expect(secondResult.success).toBe(true); + expect(secondResult.fromCache).toBe(true); + const afterCacheMtime = (await fs.stat(generatedFile)).mtimeMs; + expect(afterCacheMtime).toBe(beforeCacheMtime); + + const cachedContent = await fs.readFile(generatedFile, 'utf-8'); + expect( + cachedContent.match(/@flatbread\/content-model-types:start/g) + ).toHaveLength(1); }); it('should generate types with operations when documents are provided', async () => { @@ -310,3 +415,22 @@ describe('codegen end-to-end', () => { }); }); }); + +function expectTypeScriptToCompile(files: string[]) { + const program = ts.createProgram(files, { + noEmit: true, + strict: true, + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + skipLibCheck: true, + types: [], + }); + const diagnostics = ts.getPreEmitDiagnostics(program); + + expect( + diagnostics.map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + ) + ).toEqual([]); +} diff --git a/packages/codegen/src/__tests__/hash.test.ts b/packages/codegen/src/__tests__/hash.test.ts index caa870cf..91f375eb 100644 --- a/packages/codegen/src/__tests__/hash.test.ts +++ b/packages/codegen/src/__tests__/hash.test.ts @@ -4,6 +4,7 @@ import { hashSchema, hashDocuments, hashCodegenInputs, + CODEGEN_OUTPUT_VERSION, } from '../hash.js'; import type { LoadedFlatbreadConfig } from '@flatbread/core'; import type { CodegenOptions } from '../types.js'; @@ -45,6 +46,10 @@ describe('hash functions', () => { expect(hash1).toHaveLength(64); // SHA256 hex length }); + it('should expose a cache-busting output version', () => { + expect(CODEGEN_OUTPUT_VERSION).toBeGreaterThan(0); + }); + it('should generate different hashes for different configurations', () => { const options2 = { ...mockOptions, outputDir: './dist' }; diff --git a/packages/codegen/src/generator.ts b/packages/codegen/src/generator.ts index a5573d42..77dd9f4e 100644 --- a/packages/codegen/src/generator.ts +++ b/packages/codegen/src/generator.ts @@ -1,8 +1,18 @@ import { generate } from '@graphql-codegen/cli'; -import { printSchema, type GraphQLSchema } from 'graphql'; +import { + isListType, + isNonNullType, + isObjectType, + isScalarType, + isEnumType, + printSchema, + type GraphQLSchema, + type GraphQLType, +} from 'graphql'; import { join, resolve } from 'path'; import { ensureDir } from 'fs-extra'; import { existsSync } from 'fs'; +import { readFile, writeFile } from 'node:fs/promises'; import kleur from 'kleur'; // @ts-ignore - chokidar types will be available after npm install import chokidar from 'chokidar'; @@ -134,6 +144,7 @@ export async function generateTypes( // Generate types await generate(codegenConfig, true); + await upsertFlatbreadContentModelTypes(outputFilePath, config, schema); const generatedFiles = [outputFilePath]; @@ -167,6 +178,379 @@ export async function generateTypes( } } +const CONTENT_MODEL_TYPES_START = '/* @flatbread/content-model-types:start */'; +const CONTENT_MODEL_TYPES_END = '/* @flatbread/content-model-types:end */'; + +async function upsertFlatbreadContentModelTypes( + outputFilePath: string, + config: LoadedFlatbreadConfig, + schema: GraphQLSchema +): Promise { + const contentModelTypes = generateFlatbreadContentModelTypes(config, schema); + if (!contentModelTypes) return; + + const current = await readFile(outputFilePath, 'utf-8'); + const block = `${CONTENT_MODEL_TYPES_START}\n${contentModelTypes}\n${CONTENT_MODEL_TYPES_END}`; + const existingBlockPattern = new RegExp( + `\\n?${escapeRegExp(CONTENT_MODEL_TYPES_START)}[\\s\\S]*?${escapeRegExp( + CONTENT_MODEL_TYPES_END + )}` + ); + const next = existingBlockPattern.test(current) + ? current.replace(existingBlockPattern, `\n${block}`) + : `${current.trimEnd()}\n\n${block}\n`; + + await writeFile(outputFilePath, next); +} + +function generateFlatbreadContentModelTypes( + config: LoadedFlatbreadConfig, + schema: GraphQLSchema +): string { + const collections = config.content.map((contentType) => + String(contentType.collection) + ); + + if (collections.length === 0) { + return ''; + } + + const recordEntries = collections + .map( + (collection) => + ` ${JSON.stringify(collection)}: ${toTypeReference( + collection, + schema + )};` + ) + .join('\n'); + + const relationEntries = config.content + .map((contentType) => { + const collection = String(contentType.collection); + const refs = contentType.refs as Record | undefined; + const relationFields = refs + ? Object.entries(refs) + .map( + ([field, target]) => + ` ${JSON.stringify(field)}: { target: ${JSON.stringify( + String(target) + )}; cardinality: ${JSON.stringify( + getRelationCardinality(schema, collection, field) + )}; };` + ) + .join('\n') + : ''; + + return ` ${JSON.stringify(collection)}: {${ + relationFields ? `\n${relationFields}\n ` : '' + }};`; + }) + .join('\n'); + const readableCollections = collections.filter((collection) => + Boolean( + getReadQueries(schema, collection) && + getDefaultSelection(schema, collection) + ) + ); + const readApiEntries = readableCollections + .map((collection) => { + return ` ${JSON.stringify(collection)}: { + all(selection?: string): Promise>>>; + find(id: string | number, selection?: string): Promise> | null>; + };`; + }) + .join('\n'); + const readApiRuntimeEntries = readableCollections + .map((collection) => { + const queries = getReadQueries(schema, collection); + const defaultSelection = getDefaultSelection(schema, collection); + if (!queries || !defaultSelection) { + return ''; + } + + return ` ${JSON.stringify(collection)}: { all: ${JSON.stringify( + queries.all + )}, find: ${JSON.stringify(queries.find)}, idType: ${JSON.stringify( + queries.idType + )}, selection: ${JSON.stringify(defaultSelection)} }`; + }) + .filter(Boolean) + .join(',\n'); + + return `/** + * Flatbread content model types generated from flatbread.config.*. + * These describe configured collections and refs before any GraphQL operation documents are required. + */ +export type FlatbreadCollectionName = ${collections + .map((collection) => JSON.stringify(collection)) + .join(' | ')}; + +export type FlatbreadRecordByCollection = { +${recordEntries} +}; + +export type FlatbreadRelationTargetByCollection = { +${relationEntries} +}; + +export type FlatbreadRecord< + Collection extends FlatbreadCollectionName, +> = FlatbreadRecordByCollection[Collection]; + +export type FlatbreadRelationTarget< + Collection extends FlatbreadCollectionName, + Field extends keyof FlatbreadRelationTargetByCollection[Collection], +> = FlatbreadRecord< + Extract< + FlatbreadRelationTargetCollection, + FlatbreadCollectionName + > +> extends infer TargetRecord + ? FlatbreadRelationCardinality extends 'many' + ? ReadonlyArray | null + : TargetRecord | null + : never; + +export type FlatbreadRelationTargetCollection< + Collection extends FlatbreadCollectionName, + Field extends keyof FlatbreadRelationTargetByCollection[Collection], +> = FlatbreadRelationTargetByCollection[Collection][Field] extends { + target: infer Target; +} + ? Target + : never; + +export type FlatbreadRelationCardinality< + Collection extends FlatbreadCollectionName, + Field extends keyof FlatbreadRelationTargetByCollection[Collection], +> = FlatbreadRelationTargetByCollection[Collection][Field] extends { + cardinality: infer Cardinality; +} + ? Cardinality + : never; + +export type FlatbreadReadableCollectionName = ${ + readableCollections.length > 0 + ? readableCollections + .map((collection) => JSON.stringify(collection)) + .join(' | ') + : 'never' + }; + +export type FlatbreadGraphQLExecutor = ( + source: string, + variables?: Record, +) => Promise; + +/** + * Experimental generated read API over the Flatbread content model. + * + * The API owns collection names, root query names, IDs, and result typing. The + * current prototype still accepts a GraphQL selection string for fields; invalid + * or drifting selections are runtime GraphQL errors, not type errors. + */ +export type FlatbreadReadApi = { +${readApiEntries} +}; + +const flatbreadReadApiQueries = { +${readApiRuntimeEntries} +} as const; + +export function createFlatbreadReadApi( + execute: FlatbreadGraphQLExecutor, +): FlatbreadReadApi { + return Object.fromEntries( + Object.entries(flatbreadReadApiQueries).map(([collection, queries]) => [ + collection, + { + all: async (selection = queries.selection) => { + const readSelection = normalizeFlatbreadReadSelection(selection); + const operationName = flatbreadReadApiOperationName(collection, 'All'); + const data = await execute>>( + \`query \${operationName} { \${queries.all} { \${readSelection} } }\`, + ); + return data[queries.all] ?? []; + }, + find: async (id: string | number, selection = queries.selection) => { + const readSelection = normalizeFlatbreadReadSelection(selection); + const operationName = flatbreadReadApiOperationName(collection, 'Find'); + const data = await execute>( + \`query \${operationName}($id: \${queries.idType}) { \${queries.find}(id: $id) { \${readSelection} } }\`, + { id }, + ); + return data[queries.find] ?? null; + }, + }, + ]), + ) as FlatbreadReadApi; +} + +function normalizeFlatbreadReadSelection(selection: string): string { + const normalized = selection.trim(); + if (!normalized) { + throw new Error('Flatbread read API selection must not be empty.'); + } + return normalized; +} + +function flatbreadReadApiOperationName( + collection: string, + action: string, +): string { + const safeCollection = collection.replace(/[^A-Za-z0-9_]/g, '_'); + const suffix = safeCollection && !/^\\d/.test(safeCollection) + ? safeCollection + : \`_\${safeCollection || 'Collection'}\`; + return \`FlatbreadRead_\${suffix}_\${action}\`; +}`; +} + +function toTypeReference(collection: string, schema: GraphQLSchema): string { + const schemaType = schema.getType(collection); + + return isObjectType(schemaType) && + /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(collection) + ? collection + : 'Record'; +} + +function getRelationCardinality( + schema: GraphQLSchema, + collection: string, + field: string +): 'one' | 'many' { + const schemaType = schema.getType(collection); + if (!isObjectType(schemaType)) { + return 'one'; + } + + const fieldConfig = schemaType.getFields()[field]; + if (!fieldConfig) { + return 'one'; + } + + return isListLikeType(fieldConfig.type) ? 'many' : 'one'; +} + +function isListLikeType(type: GraphQLType): boolean { + if (isListType(type)) { + return true; + } + + if (isNonNullType(type)) { + return isListLikeType(type.ofType); + } + + return false; +} + +function getReadQueries( + schema: GraphQLSchema, + collection: string +): { all: string; find: string; idType: string } | undefined { + const queryType = schema.getQueryType(); + if (!queryType) { + return undefined; + } + + const fields = queryType.getFields(); + const find = + (fields[collection] ? collection : undefined) ?? + Object.keys(fields).find( + (fieldName) => + isNamedType(fields[fieldName].type, collection) && + fields[fieldName].args.some((arg) => arg.name === 'id') + ); + const all = Object.keys(fields).find((fieldName) => + isListOfType(fields[fieldName].type, collection) + ); + + return find && all + ? { + all, + find, + idType: + fields[find].args.find((arg) => arg.name === 'id')?.type.toString() ?? + 'ID!', + } + : undefined; +} + +function getDefaultSelection( + schema: GraphQLSchema, + collection: string, + depth = 0 +): string | undefined { + const schemaType = schema.getType(collection); + if (!isObjectType(schemaType)) { + return undefined; + } + + const selections = Object.values(schemaType.getFields()) + .map((field) => { + const namedType = unwrapType(field.type); + + if (isScalarType(namedType) || isEnumType(namedType)) { + return field.name; + } + + if (depth === 0 && isObjectType(namedType)) { + const nestedSelection = getDefaultSelection( + schema, + namedType.name, + depth + 1 + ); + + return nestedSelection + ? `${field.name} { ${nestedSelection} }` + : undefined; + } + + return undefined; + }) + .filter((selection): selection is string => Boolean(selection)); + + return selections.length > 0 ? selections.join('\n') : undefined; +} + +function unwrapType(type: GraphQLType): GraphQLType { + if (isNonNullType(type) || isListType(type)) { + return unwrapType(type.ofType); + } + + return type; +} + +function isListOfType(type: GraphQLType, collection: string): boolean { + if (isNonNullType(type)) { + return isListOfType(type.ofType, collection); + } + + if (isListType(type)) { + return isNamedType(type.ofType, collection); + } + + return false; +} + +function isNamedType(type: GraphQLType, collection: string): boolean { + if (isNonNullType(type)) { + return isNamedType(type.ofType, collection); + } + + return isObjectType(type) && type.name === collection; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Generate TypeScript types with support for document files */ diff --git a/packages/codegen/src/hash.ts b/packages/codegen/src/hash.ts index e73f648e..d92c4ef7 100644 --- a/packages/codegen/src/hash.ts +++ b/packages/codegen/src/hash.ts @@ -2,6 +2,8 @@ import { createHash } from 'crypto'; import type { LoadedFlatbreadConfig } from '@flatbread/core'; import type { CodegenOptions } from './types.js'; +export const CODEGEN_OUTPUT_VERSION = 2; + /** * Generate a hash for the Flatbread configuration * This is used to determine if types need to be regenerated @@ -36,6 +38,7 @@ export function hashConfig( fieldNameTransform: 'function', loaded: config.loaded, codegen: options, + outputVersion: CODEGEN_OUTPUT_VERSION, }, null, 2 diff --git a/packages/core/README.md b/packages/core/README.md index 30a37420..da98b94b 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -9,3 +9,25 @@ However, you can utilize this package directly to build your own custom GraphQL ```bash pnpm i @flatbread/core@latest ``` + +## Snapshot exports + +This package also exposes stable snapshot export helpers for the validated +content graph: + +- `exportCollectionsAsJson(configResult, options)` returns deterministic JSON + snapshots for selected collections. +- `exportCollectionsAsCsv(configResult, options)` returns flat CSV views over + that same validated data. + +Prefer importing these helpers from `flatbread` in app code, since the main +package re-exports `@flatbread/core` and is the primary consumer-facing surface. +Import from `@flatbread/core` directly when you intentionally want the lower +level package: + +```ts +import { exportCollectionsAsJson, exportCollectionsAsCsv } from 'flatbread'; +``` + +See [`docs/json-export.md`](../../docs/json-export.md) for the full export +contract and examples. diff --git a/packages/core/src/export/csv.ts b/packages/core/src/export/csv.ts new file mode 100644 index 00000000..c47370ff --- /dev/null +++ b/packages/core/src/export/csv.ts @@ -0,0 +1,120 @@ +import { ConfigResult, EntryNode, LoadedFlatbreadConfig } from '../types'; +import { exportCollectionsAsJson, JsonExportOptions } from './json'; + +export interface CsvExportOptions extends JsonExportOptions { + delimiter?: ',' | ';' | '\t'; + relationSeparator?: string; +} + +export type CsvExportResult = Record; + +/** + * Export selected Flatbread collections as deterministic flat CSV views. + * + * CSV is intentionally a flat view: + * - scalar fields are emitted as columns; + * - scalar arrays and relation-id arrays are joined with `relationSeparator`; + * - object-valued fields are omitted because they do not have a stable flat + * representation yet. + */ +export async function exportCollectionsAsCsv( + configResult: ConfigResult, + options: CsvExportOptions = {} +): Promise { + const json = await exportCollectionsAsJson(configResult, options); + const delimiter = options.delimiter ?? ','; + const relationSeparator = options.relationSeparator ?? ';'; + + return Object.fromEntries( + Object.entries(json).map(([collection, records]) => [ + collection, + serializeCollection(records, delimiter, relationSeparator), + ]) + ); +} + +function serializeCollection( + records: EntryNode[], + delimiter: string, + relationSeparator: string +): string { + const headers = collectHeaders(records); + if (headers.length === 0) { + return ''; + } + + const rows = records.map((record) => + headers.map((header) => formatCell(record[header], relationSeparator)) + ); + + return [headers, ...rows] + .map((row) => + row.map((cell) => escapeCsvCell(cell, delimiter)).join(delimiter) + ) + .join('\n') + .concat('\n'); +} + +function collectHeaders(records: EntryNode[]): string[] { + const headers = new Set(); + for (const record of records) { + for (const [key, value] of Object.entries(record)) { + if (isCsvValue(value)) { + headers.add(key); + } + } + } + + return [...headers].sort((left, right) => + left === 'id' ? -1 : right === 'id' ? 1 : compareCodepoint(left, right) + ); +} + +function formatCell(value: unknown, relationSeparator: string): string { + if (value === null || value === undefined) { + return ''; + } + + if (Array.isArray(value)) { + return value.map((item) => formatScalar(item)).join(relationSeparator); + } + + return formatScalar(value); +} + +function formatScalar(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return ''; +} + +function escapeCsvCell(cell: string, delimiter: string): string { + if (cell.includes(delimiter) || /["\n\r]/.test(cell)) { + return `"${cell.replace(/"/g, '""')}"`; + } + + return cell; +} + +function isCsvValue(value: unknown): boolean { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + (Array.isArray(value) && + value.every( + (item) => + typeof item === 'string' || + typeof item === 'number' || + typeof item === 'boolean' || + item === null || + item === undefined + )) + ); +} + +function compareCodepoint(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/core/src/export/json.ts b/packages/core/src/export/json.ts new file mode 100644 index 00000000..2117e892 --- /dev/null +++ b/packages/core/src/export/json.ts @@ -0,0 +1,177 @@ +import { VFile } from 'vfile'; +import { relative } from 'node:path'; +import { generateSchema } from '../generators/schema'; +import { + ConfigResult, + ContentEntry, + EntryNode, + LoadedFlatbreadConfig, + Transformer, +} from '../types'; +import { normalizeIdentifier } from '../utils/ids'; + +export interface JsonExportOptions { + collections?: readonly string[]; + pathRoot?: string; +} + +export type JsonExportResult = Record; + +/** + * Export selected Flatbread collections as deterministic JSON-ready objects. + * + * Stability contract: + * - collection names, record IDs, and object keys are sorted by Unicode + * codepoint order; + * - record IDs and configured relation fields are normalized with the same ID + * semantics used by query resolvers; + * - `_path` is emitted relative to `pathRoot` (default: `process.cwd()`); + * - invalid IDs/refs fail through the same validation gate as schema + * generation before export output is returned. + */ +export async function exportCollectionsAsJson( + configResult: ConfigResult, + options: JsonExportOptions = {} +): Promise { + const { config } = configResult; + if (!config) { + throw new Error('Config is not defined'); + } + + // Reuse schema generation as the validation gate so exports cannot silently + // serialize broken ids or refs. + await generateSchema(configResult); + + config.source.initialize?.(config); + const rawNodes = await config.source.fetch(config.content); + const transformerByExtension = getTransformerExtensionMap(config.transformer); + const selected = new Set( + options.collections ?? config.content.map((entry) => entry.collection) + ); + const contentByCollection = new Map( + config.content.map((entry) => [entry.collection, entry]) + ); + const unknownCollections = [...selected].filter( + (collection) => !contentByCollection.has(collection) + ); + if (unknownCollections.length > 0) { + throw new Error( + `Cannot export unknown collection${ + unknownCollections.length === 1 ? '' : 's' + }: ${unknownCollections.join(', ')}` + ); + } + const result: JsonExportResult = {}; + + for (const [collection, nodes] of Object.entries(rawNodes)) { + if (!selected.has(collection)) continue; + + const contentEntry = contentByCollection.get(collection); + const records = nodes + .map((node) => parseNode(node, transformerByExtension)) + .map((entry) => + normalizeRecord(entry, contentEntry, options.pathRoot ?? process.cwd()) + ) + .sort((a, b) => + compareCodepoint( + normalizeIdentifier(a.id, `${collection} export id`), + normalizeIdentifier(b.id, `${collection} export id`) + ) + ); + + result[collection] = records.map(sortObjectKeys) as EntryNode[]; + } + + return Object.fromEntries( + Object.entries(result).sort(([collectionA], [collectionB]) => + compareCodepoint(collectionA, collectionB) + ) + ); +} + +function getTransformerExtensionMap( + transformer: Transformer[] +): Map { + const transformerMap = new Map(); + transformer.forEach((nextTransformer) => { + nextTransformer.extensions.forEach((extension) => { + transformerMap.set(extension, nextTransformer); + }); + }); + return transformerMap; +} + +function parseNode( + node: VFile, + transformerByExtension: Map +): EntryNode { + const transformer = transformerByExtension.get(node.extname ?? ''); + if (!transformer?.parse) { + throw new Error(`no transformer found for ${node.path}`); + } + + return { + ...transformer.parse(node), + _path: node.path, + _filename: node.basename, + }; +} + +function normalizeRecord( + entry: EntryNode, + contentEntry: ContentEntry | undefined, + pathRoot: string +): EntryNode { + const normalized: EntryNode = { + ...entry, + id: normalizeIdentifier(entry.id, 'export record id'), + }; + + if (typeof normalized._path === 'string') { + normalized._path = relative(pathRoot, normalized._path); + } + + for (const refField of Object.keys(contentEntry?.refs ?? {})) { + const value = normalized[refField]; + if (Array.isArray(value)) { + normalized[refField] = value.map((item) => + normalizeIdentifier(item, `export relation "${refField}"`) + ); + } else if (value !== null && value !== undefined) { + normalized[refField] = normalizeIdentifier( + value, + `export relation "${refField}"` + ); + } + } + + return normalized; +} + +function sortObjectKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortObjectKeys); + } + + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value) + .sort(([keyA], [keyB]) => compareCodepoint(keyA, keyB)) + .map(([key, nestedValue]) => [key, sortObjectKeys(nestedValue)]) + ); + } + + return value; +} + +function compareCodepoint(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + Object.getPrototypeOf(value) === Object.prototype + ); +} diff --git a/packages/core/src/export/tests/csv.test.ts b/packages/core/src/export/tests/csv.test.ts new file mode 100644 index 00000000..ae6449c3 --- /dev/null +++ b/packages/core/src/export/tests/csv.test.ts @@ -0,0 +1,127 @@ +import test from 'ava'; +import filesystem from '@flatbread/source-filesystem'; +import markdownTransformer from '@flatbread/transformer-markdown'; +import { exportCollectionsAsCsv } from '../csv'; +import { initializeConfig } from '../../utils/initializeConfig'; + +test('exports selected collections as flat CSV with relation IDs', async (t) => { + const config = initializeConfig({ + source: filesystem(), + transformer: markdownTransformer(), + content: [ + { + path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/authors', + collection: 'Author', + }, + { + path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/tags', + collection: 'Tag', + }, + { + path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/posts', + collection: 'Post', + refs: { + author: 'Author', + authors: 'Author', + tags: 'Tag', + }, + }, + ], + }); + + const result = await exportCollectionsAsCsv( + { config }, + { collections: ['Post'] } + ); + + t.deepEqual(Object.keys(result), ['Post']); + t.is( + result.Post, + [ + 'id,_filename,_path,_slug,author,authors,tags,title', + 'known-post,known-post.md,packages/core/src/providers/test/fixtures/missing-refs-clean/posts/known-post.md,known-post,known-author,known-author,known-tag,Post With Resolved Refs', + '', + ].join('\n') + ); +}); + +test('escapes CSV cells with delimiters, quotes, and newlines', async (t) => { + const config = initializeConfig({ + source: { + fetch: async () => ({ + Quote: [ + { + basename: 'quote.md', + extname: '.md', + path: `${process.cwd()}/quote.md`, + value: 'ignored', + }, + ] as never, + }), + }, + transformer: [ + { + extensions: ['.md'], + inspect: () => 'quote', + parse: () => ({ + id: 'quote', + title: 'Comma, "quote"\nand newline', + }), + }, + ], + content: [ + { + path: 'virtual/quotes', + collection: 'Quote', + }, + ], + }); + + const result = await exportCollectionsAsCsv( + { config }, + { collections: ['Quote'] } + ); + + t.is( + result.Quote, + 'id,_filename,_path,title\nquote,quote.md,quote.md,"Comma, ""quote""\nand newline"\n' + ); +}); + +test('supports custom delimiters and relation separators', async (t) => { + const config = initializeConfig({ + source: filesystem(), + transformer: markdownTransformer(), + content: [ + { + path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/authors', + collection: 'Author', + }, + { + path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/tags', + collection: 'Tag', + }, + { + path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/posts', + collection: 'Post', + refs: { + author: 'Author', + authors: 'Author', + tags: 'Tag', + }, + }, + ], + }); + + const result = await exportCollectionsAsCsv( + { config }, + { + collections: ['Post'], + delimiter: '\t', + relationSeparator: '|', + } + ); + + t.true(result.Post.startsWith('id\t_filename\t_path\t_slug')); + t.true(result.Post.includes('\tknown-author\tknown-author\tknown-tag\t')); +}); diff --git a/packages/core/src/export/tests/json.test.ts b/packages/core/src/export/tests/json.test.ts new file mode 100644 index 00000000..d73edb53 --- /dev/null +++ b/packages/core/src/export/tests/json.test.ts @@ -0,0 +1,92 @@ +import test from 'ava'; +import filesystem from '@flatbread/source-filesystem'; +import markdownTransformer from '@flatbread/transformer-markdown'; +import { exportCollectionsAsJson } from '../json'; +import { initializeConfig } from '../../utils/initializeConfig'; + +test('exports selected collections as stable normalized JSON', async (t) => { + const config = initializeConfig({ + source: filesystem(), + transformer: markdownTransformer(), + content: [ + { + path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/authors', + collection: 'Author', + }, + { + path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/tags', + collection: 'Tag', + }, + { + path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/posts', + collection: 'Post', + refs: { + author: 'Author', + authors: 'Author', + tags: 'Tag', + }, + }, + ], + }); + + const result = await exportCollectionsAsJson( + { config }, + { collections: ['Post'] } + ); + + t.deepEqual(Object.keys(result), ['Post']); + t.deepEqual(result.Post, [ + { + _content: { + raw: '\nAll references resolve, so missing-ref validation must remain silent and the\nschema should build cleanly.\n', + }, + _filename: 'known-post.md', + _path: + 'packages/core/src/providers/test/fixtures/missing-refs-clean/posts/known-post.md', + _slug: 'known-post', + author: 'known-author', + authors: ['known-author'], + id: 'known-post', + tags: ['known-tag'], + title: 'Post With Resolved Refs', + }, + ]); +}); + +test('rejects unknown selected collections', async (t) => { + const config = initializeConfig({ + source: filesystem(), + transformer: markdownTransformer(), + content: [ + { + path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/authors', + collection: 'Author', + }, + ], + }); + + const error = await t.throwsAsync(() => + exportCollectionsAsJson({ config }, { collections: ['Missing'] }) + ); + + t.is(error?.message, 'Cannot export unknown collection: Missing'); +}); + +test('reuses validation diagnostics before exporting JSON', async (t) => { + const config = initializeConfig({ + source: filesystem(), + transformer: markdownTransformer(), + content: [ + { + path: 'packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors', + collection: 'Author', + }, + ], + }); + + const error = await t.throwsAsync(() => + exportCollectionsAsJson({ config }, { collections: ['Author'] }) + ); + + t.regex(error?.message ?? '', /Author record id "123" is duplicated/); +}); diff --git a/packages/core/src/generators/arguments.ts b/packages/core/src/generators/arguments.ts index f6c1ded9..939f329a 100644 --- a/packages/core/src/generators/arguments.ts +++ b/packages/core/src/generators/arguments.ts @@ -18,7 +18,7 @@ export const generateArgsForAllItemQuery = (pluralType: string) => ({ */ export const generateArgsForManyItemQuery = (pluralType: string) => ({ ids: { - type: '[String]', + type: '[ID]', }, ...skip(), ...limit(pluralType), @@ -32,7 +32,7 @@ export const generateArgsForManyItemQuery = (pluralType: string) => ({ */ export const generateArgsForSingleItemQuery = () => ({ id: { - type: 'String', + type: 'ID', }, }); diff --git a/packages/core/src/generators/generateCollection.ts b/packages/core/src/generators/generateCollection.ts index d939fba9..2b98b093 100644 --- a/packages/core/src/generators/generateCollection.ts +++ b/packages/core/src/generators/generateCollection.ts @@ -1,5 +1,5 @@ import { defaultsDeep, merge } from 'lodash-es'; -import { LoadedFlatbreadConfig } from '../types'; +import { EntryNode, LoadedFlatbreadConfig } from '../types'; import { getFieldOverrides } from '../utils/fieldOverrides'; import transformKeys from '../utils/transformKeys'; @@ -7,7 +7,7 @@ interface GenerateCollectionArgs { collection: string; nodes: T[]; config: LoadedFlatbreadConfig; - preknownSchemaFragments: Record; + preknownSchemaFragments: Record; } export function generateCollection({ @@ -15,8 +15,8 @@ export function generateCollection({ preknownSchemaFragments, config, nodes, -}: GenerateCollectionArgs) { - return transformKeys( +}: GenerateCollectionArgs): EntryNode { + const transformed = transformKeys( defaultsDeep( {}, getFieldOverrides(collection, config), @@ -24,4 +24,16 @@ export function generateCollection({ ), config.fieldNameTransform ); + + if (!isEntryNode(transformed)) { + throw new Error( + `Generated collection "${collection}" did not produce an object schema.` + ); + } + + return transformed; +} + +function isEntryNode(value: unknown): value is EntryNode { + return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/packages/core/src/generators/schema.ts b/packages/core/src/generators/schema.ts index 72dba8a3..01107308 100644 --- a/packages/core/src/generators/schema.ts +++ b/packages/core/src/generators/schema.ts @@ -12,11 +12,18 @@ import { import resolveQueryArgs from '../resolvers/arguments'; import { ConfigResult, + ContentNode, EntryNode, LoadedFlatbreadConfig, Transformer, } from '../types'; import { map } from '../utils/map'; +import { + getNodeIdentifier, + normalizeIdentifier, + normalizeOptionalIdentifier, +} from '../utils/ids'; +import { validateCollectionReferences } from '../utils/references'; import { generateCollection } from './generateCollection'; interface RootQueries { @@ -24,6 +31,10 @@ interface RootQueries { maybeReturnsList: string[]; } +interface ResolverPayload { + args: Record; +} + /** * Generates a GraphQL schema from content nodes. * @@ -37,13 +48,6 @@ export async function generateSchema( throw new Error('Config is not defined'); } - // Let's see if we have a cached version of the schema. If so, short-circuit and return it. - const cachedSchema = checkCacheForSchema(config); - - if (cachedSchema) { - return cachedSchema; - } - // Invoke initialize function if it exists and provide loaded config config.source.initialize?.(config); @@ -55,6 +59,22 @@ export async function generateSchema( allContentNodes, config ); + const contentNodesByCollection = + validateCollectionIdentifiers(allContentNodesJSON); + validateCollectionReferences(allContentNodesJSON, config.content); + + // Content validation must run before returning a cached schema because the + // cache key is derived from config, while invalid IDs/refs live in content. + const cachedSchema = checkCacheForSchema(config); + + if (cachedSchema) { + return cachedSchema; + } + + // graphql-compose's default schemaComposer is process-global. Reset it before + // building a fresh Flatbread schema so prior schemas with the same collection + // names do not leak fields or resolvers into this generation pass. + schemaComposer.clear(); const preknownSchemaFragments = fetchPreknownSchemaFragments(config); @@ -114,10 +134,20 @@ export async function generateSchema( type: () => schema, description: `Find one ${type} by its ID`, args: generateArgsForSingleItemQuery(), - resolve: (rp: Record) => - cloneDeep(allContentNodesJSON[type]).find( - (node: EntryNode) => node.id === rp.args.id - ), + resolve: (rp: ResolverPayload) => { + const idToFind = normalizeOptionalIdentifier( + rp.args.id, + `${type} query argument "id"` + ); + + if (idToFind === undefined) { + return undefined; + } + + return cloneDeep(contentNodesByCollection[type]).find( + (node: ContentNode) => getNodeIdentifier(node, type) === idToFind + ); + }, }); schema.addResolver({ @@ -125,11 +155,20 @@ export async function generateSchema( type: () => [schema], description: `Find many ${pluralType} by their IDs`, args: generateArgsForManyItemQuery(pluralType), - resolve: (rp: Record) => { - const idsToFind = rp.args.ids ?? []; + resolve: (rp: ResolverPayload) => { + if (rp.args.ids !== undefined && !Array.isArray(rp.args.ids)) { + throw new Error( + `${type} query argument "ids" must be an array of identifiers.` + ); + } + const idsArg = rp.args.ids ?? []; + const idsToFind = idsArg.map((id: unknown): string => + normalizeIdentifier(id, `${type} query argument "ids"`) + ); const matches = - cloneDeep(allContentNodesJSON[type])?.filter((node: EntryNode) => - idsToFind?.includes(node.id) + cloneDeep(contentNodesByCollection[type])?.filter( + (node: ContentNode) => + idsToFind?.includes(getNodeIdentifier(node, type)) ) ?? []; return resolveQueryArgs(matches, rp.args, config, { type: { @@ -146,8 +185,8 @@ export async function generateSchema( args: generateArgsForAllItemQuery(pluralType), type: () => [schema], description: `Return a set of ${pluralType}`, - resolve: (rp: Record) => { - const nodes = cloneDeep(allContentNodesJSON[type]); + resolve: (rp: ResolverPayload) => { + const nodes = cloneDeep(contentNodesByCollection[type]); return resolveQueryArgs(nodes, rp.args, config, { type: { name: type, @@ -233,15 +272,64 @@ export async function generateSchema( */ const fetchPreknownSchemaFragments = ( config: LoadedFlatbreadConfig -): Record | {} => { +): Record => { return config.transformer.reduce( (all, next) => merge(all, next.preknownSchemaFragments?.() || {}), {} ); }; -function getTransformerExtensionMap(transformer: Transformer[]) { - const transformerMap = new Map(); +function validateCollectionIdentifiers( + allContentNodesJSON: Record +): Record { + const errors: string[] = []; + const contentNodesByCollection: Record = {}; + + Object.entries(allContentNodesJSON).forEach(([collection, nodes]) => { + const seen = new Map(); + contentNodesByCollection[collection] = []; + + nodes.forEach((node) => { + try { + const normalizedId = getNodeIdentifier(node, collection); + const existing = seen.get(normalizedId); + + if (existing) { + errors.push( + `${collection} record id "${normalizedId}" is duplicated after normalization${sourceContext( + existing + )}${sourceContext(node)}` + ); + } else { + seen.set(normalizedId, node); + } + contentNodesByCollection[collection].push(node as ContentNode); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + }); + }); + + if (errors.length > 0) { + errors.sort(); + throw new Error( + `Flatbread found ${errors.length} invalid record ID${ + errors.length === 1 ? '' : 's' + }:\n${errors.map((message) => `- ${message}`).join('\n')}` + ); + } + + return contentNodesByCollection; +} + +function sourceContext(node: EntryNode): string { + return typeof node._path === 'string' ? ` (${node._path})` : ''; +} + +function getTransformerExtensionMap( + transformer: Transformer[] +): Map { + const transformerMap = new Map(); transformer.forEach((t) => { t.extensions.forEach((extension) => { transformerMap.set(extension, t); @@ -257,9 +345,9 @@ function getTransformerExtensionMap(transformer: Transformer[]) { * @param config Flatbread config object */ const optionallyTransformContentNodes = ( - allContentNodes: Record, + allContentNodes: Record, config: LoadedFlatbreadConfig -): Record => { +): Record => { if (config.transformer) { const transformerMap = getTransformerExtensionMap(config.transformer); // const globs = Object.entries(transformers); @@ -273,13 +361,21 @@ const optionallyTransformContentNodes = ( * */ return map(allContentNodes, (node: VFile) => { - const transformer = transformerMap.get(node.extname); + const transformer = transformerMap.get(node.extname ?? ''); if (!transformer?.parse) { throw new Error(`no transformer found for ${node.path}`); } - return transformer.parse(node); + return withSourceContext(transformer.parse(node), node); }); } - return allContentNodes; + return allContentNodes as unknown as Record; }; + +function withSourceContext(entry: EntryNode, sourceNode: VFile): EntryNode { + return { + ...entry, + _path: sourceNode.path, + _filename: sourceNode.basename, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 14800794..284097db 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,16 @@ export { generateSchema } from './generators/schema'; +export { exportCollectionsAsCsv } from './export/csv'; +export type { CsvExportOptions, CsvExportResult } from './export/csv'; +export { exportCollectionsAsJson } from './export/json'; +export type { JsonExportOptions, JsonExportResult } from './export/json'; export { initializeConfig } from './utils/initializeConfig'; +export { + getNodeIdentifier, + isIdentifierField, + normalizeIdentifier, + normalizeOptionalIdentifier, +} from './utils/ids'; +export { validateCollectionReferences } from './utils/references'; export * from './types'; export { FlatbreadProvider } from './providers/base'; diff --git a/packages/core/src/providers/test/base.test.ts b/packages/core/src/providers/test/base.test.ts index 145c2f45..6d6905f3 100644 --- a/packages/core/src/providers/test/base.test.ts +++ b/packages/core/src/providers/test/base.test.ts @@ -2,6 +2,10 @@ import test from 'ava'; import filesystem from '@flatbread/source-filesystem'; import markdownTransformer from '@flatbread/transformer-markdown'; import { FlatbreadProvider } from '../base'; +import { generateSchema } from '../../generators/schema'; +import { initializeConfig } from '../../utils/initializeConfig'; +import type { EntryNode, Transformer } from '../../types'; +import { VFile } from 'vfile'; function basicProject() { return new FlatbreadProvider({ @@ -25,7 +29,29 @@ function basicProject() { }); } -test('basic query', async (t) => { +function idSemanticsProject( + path = 'packages/core/src/providers/test/fixtures/id-semantics' +) { + return new FlatbreadProvider({ + source: filesystem(), + transformer: markdownTransformer(), + content: [ + { + path: `${path}/authors`, + collection: 'Author', + }, + { + path: `${path}/posts`, + collection: 'Post', + refs: { + author: 'Author', + }, + }, + ], + }); +} + +test.serial('basic query', async (t) => { const flatbread = basicProject(); const result = await flatbread.query({ @@ -42,7 +68,166 @@ test('basic query', async (t) => { t.snapshot(result); }); -test('relational filter query', async (t) => { +test.serial( + 'normalizes numeric record IDs and string query args', + async (t) => { + const flatbread = idSemanticsProject(); + + const result = await flatbread.query({ + source: ` + query NumericAuthor { + Author(id: "123") { + id + name + } + } + `, + }); + + t.deepEqual(result.data, { + Author: { + id: 123, + name: 'Numeric Author', + }, + }); + } +); + +test.serial( + 'accepts GraphQL ID integer literals for numeric record IDs', + async (t) => { + const flatbread = idSemanticsProject(); + + const result = await flatbread.query({ + source: ` + query NumericAuthorIntegerLiteral { + Author(id: 123) { + id + name + } + } + `, + }); + + t.deepEqual(result.data, { + Author: { + id: 123, + name: 'Numeric Author', + }, + }); + } +); + +test.serial( + 'normalizes numeric relation targets when resolving refs', + async (t) => { + const flatbread = idSemanticsProject(); + + const result = await flatbread.query({ + source: ` + query NumericAuthorRelation { + allPosts { + id + title + author { + id + name + } + } + } + `, + }); + + t.deepEqual(result.data, { + allPosts: [ + { + id: 'numeric-author-post', + title: 'Numeric Author Post', + author: { + id: 123, + name: 'Numeric Author', + }, + }, + ], + }); + } +); + +test.serial( + 'normalizes ID filter values against numeric record IDs', + async (t) => { + const flatbread = idSemanticsProject(); + + const result = await flatbread.query({ + source: ` + query NumericAuthorFilter { + allAuthors(filter: {id: {eq: "123"}}) { + id + name + } + } + `, + }); + + t.deepEqual(result.data, { + allAuthors: [ + { + id: 123, + name: 'Numeric Author', + }, + ], + }); + } +); + +test.serial('rejects invalid record IDs before schema use', async (t) => { + const flatbread = idSemanticsProject( + 'packages/core/src/providers/test/fixtures/id-semantics-invalid' + ); + + const error = await t.throwsAsync(() => + flatbread.query({ + source: ` + query InvalidId { + allAuthors { + id + } + } + `, + }) + ); + + t.regex(error?.message ?? '', /Flatbread found 2 invalid record IDs/); + t.regex(error?.message ?? '', /empty-id\.md/); + t.regex(error?.message ?? '', /boolean-id\.md/); +}); + +test.serial( + 'rejects duplicate normalized record IDs before schema use', + async (t) => { + const flatbread = idSemanticsProject( + 'packages/core/src/providers/test/fixtures/id-semantics-duplicates' + ); + + const error = await t.throwsAsync(() => + flatbread.query({ + source: ` + query DuplicateId { + allAuthors { + id + } + } + `, + }) + ); + + t.regex(error?.message ?? '', /Author record id "123" is duplicated/); + t.regex(error?.message ?? '', /numeric\.md/); + t.regex(error?.message ?? '', /string\.md/); + } +); + +test.serial('relational filter query', async (t) => { const flatbread = basicProject(); const result = await flatbread.query({ @@ -56,5 +241,65 @@ test('relational filter query', async (t) => { `, }); - t.snapshot(result); + t.deepEqual(result.data, { + allAuthors: [ + { + enjoys: ['cats', 'coffee', 'design'], + name: 'Daes', + }, + { + enjoys: ['cats', 'tea', 'making this'], + name: 'Tony', + }, + ], + }); }); + +test.serial( + 'validates duplicate IDs before returning a cached schema', + async (t) => { + let authorEntries: EntryNode[] = [{ id: 'author-one', name: 'Author One' }]; + const collection = 'CacheDuplicateAuthor'; + const transformer: Transformer = { + extensions: ['.json'], + inspect: (input) => JSON.stringify(input), + parse: (input) => input.data.entry as EntryNode, + }; + const config = initializeConfig({ + source: { + fetch: async () => ({ + [collection]: authorEntries.map((entry) => { + const file = new VFile({ + path: `virtual/authors/${String(entry.id)}.json`, + }); + file.data.entry = entry; + return file; + }), + }), + }, + transformer, + content: [ + { + path: 'virtual/authors', + collection, + }, + ], + }); + + await generateSchema({ config }); + + authorEntries = [ + { id: 'author-one', name: 'Author One' }, + { id: ' author-one ', name: 'Duplicate Author One' }, + ]; + + const error = await t.throwsAsync(() => generateSchema({ config })); + + t.regex( + error?.message ?? '', + /CacheDuplicateAuthor record id "author-one" is duplicated/ + ); + t.regex(error?.message ?? '', /virtual\/authors\/author-one\.json/); + t.regex(error?.message ?? '', /virtual\/authors\/ author-one \.json/); + } +); diff --git a/packages/core/src/providers/test/fixtures/cardinality-many-to-many/posts/known-post.md b/packages/core/src/providers/test/fixtures/cardinality-many-to-many/posts/known-post.md new file mode 100644 index 00000000..27d881d6 --- /dev/null +++ b/packages/core/src/providers/test/fixtures/cardinality-many-to-many/posts/known-post.md @@ -0,0 +1,9 @@ +--- +id: known-post +title: Known Post +tags: + - known-tag +--- + +This post and tag point at each other with list refs to model a many-to-many +relationship without an inferred join table. diff --git a/packages/core/src/providers/test/fixtures/cardinality-many-to-many/tags/known-tag.md b/packages/core/src/providers/test/fixtures/cardinality-many-to-many/tags/known-tag.md new file mode 100644 index 00000000..e650c544 --- /dev/null +++ b/packages/core/src/providers/test/fixtures/cardinality-many-to-many/tags/known-tag.md @@ -0,0 +1,8 @@ +--- +id: known-tag +label: Known Tag +posts: + - known-post +--- + +This tag points back at posts with its own list ref. diff --git a/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/numeric.md b/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/numeric.md new file mode 100644 index 00000000..beb4c245 --- /dev/null +++ b/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/numeric.md @@ -0,0 +1,6 @@ +--- +id: 123 +name: Numeric Author +--- + +This fixture collides with the string ID after normalization. diff --git a/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/string.md b/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/string.md new file mode 100644 index 00000000..8a0e15ed --- /dev/null +++ b/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/string.md @@ -0,0 +1,6 @@ +--- +id: '123' +name: String Author +--- + +This fixture collides with the numeric ID after normalization. diff --git a/packages/core/src/providers/test/fixtures/id-semantics-duplicates/posts/valid.md b/packages/core/src/providers/test/fixtures/id-semantics-duplicates/posts/valid.md new file mode 100644 index 00000000..dd8a1da0 --- /dev/null +++ b/packages/core/src/providers/test/fixtures/id-semantics-duplicates/posts/valid.md @@ -0,0 +1,7 @@ +--- +id: duplicate-id-post +title: Duplicate ID Post +author: 123 +--- + +This post only exists so the duplicate-id fixture has every configured folder. diff --git a/packages/core/src/providers/test/fixtures/id-semantics-invalid/authors/boolean-id.md b/packages/core/src/providers/test/fixtures/id-semantics-invalid/authors/boolean-id.md new file mode 100644 index 00000000..abea50db --- /dev/null +++ b/packages/core/src/providers/test/fixtures/id-semantics-invalid/authors/boolean-id.md @@ -0,0 +1,7 @@ +--- +id: true +name: Boolean ID Author +--- + +This fixture should fail alongside the empty ID fixture so diagnostics aggregate +multiple invalid IDs. diff --git a/packages/core/src/providers/test/fixtures/id-semantics-invalid/authors/empty-id.md b/packages/core/src/providers/test/fixtures/id-semantics-invalid/authors/empty-id.md new file mode 100644 index 00000000..f19785d3 --- /dev/null +++ b/packages/core/src/providers/test/fixtures/id-semantics-invalid/authors/empty-id.md @@ -0,0 +1,6 @@ +--- +id: '' +name: Empty ID Author +--- + +This fixture should fail ID validation before a schema is usable. diff --git a/packages/core/src/providers/test/fixtures/id-semantics-invalid/posts/valid.md b/packages/core/src/providers/test/fixtures/id-semantics-invalid/posts/valid.md new file mode 100644 index 00000000..2bc65a05 --- /dev/null +++ b/packages/core/src/providers/test/fixtures/id-semantics-invalid/posts/valid.md @@ -0,0 +1,7 @@ +--- +id: valid-post +title: Valid Post +author: '' +--- + +This post only exists so the invalid-id fixture has every configured folder. diff --git a/packages/core/src/providers/test/fixtures/id-semantics/authors/numeric.md b/packages/core/src/providers/test/fixtures/id-semantics/authors/numeric.md new file mode 100644 index 00000000..5388586d --- /dev/null +++ b/packages/core/src/providers/test/fixtures/id-semantics/authors/numeric.md @@ -0,0 +1,7 @@ +--- +id: 123 +name: Numeric Author +--- + +This author intentionally uses a numeric ID to prove query args and relation +targets normalize to the same comparison form. diff --git a/packages/core/src/providers/test/fixtures/id-semantics/posts/numeric-author.md b/packages/core/src/providers/test/fixtures/id-semantics/posts/numeric-author.md new file mode 100644 index 00000000..a3e78337 --- /dev/null +++ b/packages/core/src/providers/test/fixtures/id-semantics/posts/numeric-author.md @@ -0,0 +1,7 @@ +--- +id: numeric-author-post +title: Numeric Author Post +author: 123 +--- + +This post points at an author through a numeric relation target. diff --git a/packages/core/src/providers/test/fixtures/missing-refs-clean/authors/known-author.md b/packages/core/src/providers/test/fixtures/missing-refs-clean/authors/known-author.md new file mode 100644 index 00000000..928734f5 --- /dev/null +++ b/packages/core/src/providers/test/fixtures/missing-refs-clean/authors/known-author.md @@ -0,0 +1,8 @@ +--- +id: known-author +name: Known Author +--- + +Green-path sibling for the missing-refs fixture tree: identical to its +counterpart so we can prove the validator does not over-trigger when refs +resolve. diff --git a/packages/core/src/providers/test/fixtures/missing-refs-clean/posts/known-post.md b/packages/core/src/providers/test/fixtures/missing-refs-clean/posts/known-post.md new file mode 100644 index 00000000..4296763c --- /dev/null +++ b/packages/core/src/providers/test/fixtures/missing-refs-clean/posts/known-post.md @@ -0,0 +1,12 @@ +--- +id: known-post +title: Post With Resolved Refs +author: known-author +authors: + - known-author +tags: + - known-tag +--- + +All references resolve, so missing-ref validation must remain silent and the +schema should build cleanly. diff --git a/packages/core/src/providers/test/fixtures/missing-refs-clean/tags/known-tag.md b/packages/core/src/providers/test/fixtures/missing-refs-clean/tags/known-tag.md new file mode 100644 index 00000000..4b4a5343 --- /dev/null +++ b/packages/core/src/providers/test/fixtures/missing-refs-clean/tags/known-tag.md @@ -0,0 +1,6 @@ +--- +id: known-tag +name: Known Tag +--- + +Green-path sibling for the missing-refs fixture tree. diff --git a/packages/core/src/providers/test/fixtures/missing-refs/authors/known-author.md b/packages/core/src/providers/test/fixtures/missing-refs/authors/known-author.md new file mode 100644 index 00000000..53cb26ae --- /dev/null +++ b/packages/core/src/providers/test/fixtures/missing-refs/authors/known-author.md @@ -0,0 +1,7 @@ +--- +id: known-author +name: Known Author +--- + +This author is the only valid target for refs in this fixture tree, so any +post pointing elsewhere will produce a missing-reference diagnostic. diff --git a/packages/core/src/providers/test/fixtures/missing-refs/posts/has-bad-author-shape.md b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-bad-author-shape.md new file mode 100644 index 00000000..27536f5c --- /dev/null +++ b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-bad-author-shape.md @@ -0,0 +1,10 @@ +--- +id: post-bad-shape +title: Post With Non-Identifier Author +author: true +authors: + - known-author +--- + +`author` (scalar relation) is set to a boolean to drive the invalid-shape +diagnostic path through the relation context. diff --git a/packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-author.md b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-author.md new file mode 100644 index 00000000..99508a1a --- /dev/null +++ b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-author.md @@ -0,0 +1,10 @@ +--- +id: post-missing-author +title: Post With Ghost Author +authors: + - known-author + - ghost-author +--- + +The second entry in `authors` points at an Author record that does not exist in +the fixture tree, so the missing-ref validator must surface it before schema use. diff --git a/packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-tag.md b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-tag.md new file mode 100644 index 00000000..c84e321f --- /dev/null +++ b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-tag.md @@ -0,0 +1,13 @@ +--- +id: post-missing-tag +title: Post With Ghost Tag +authors: + - known-author +tags: + - known-tag + - ghost-tag +--- + +This fixture exercises the "tag is a relation, not a facet" case from the +glossary: `tags` is configured as a `Tag` collection ref so the validator +should report `ghost-tag` as missing. diff --git a/packages/core/src/providers/test/fixtures/missing-refs/posts/has-nested-authors-shape.md b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-nested-authors-shape.md new file mode 100644 index 00000000..b0f84d2e --- /dev/null +++ b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-nested-authors-shape.md @@ -0,0 +1,9 @@ +--- +id: post-nested-authors +title: Post With Nested Authors +authors: + - - known-author +--- + +Nested arrays are not supported as relation values; refs should be scalar IDs +or flat arrays of scalar IDs. diff --git a/packages/core/src/providers/test/fixtures/missing-refs/posts/has-object-author-shape.md b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-object-author-shape.md new file mode 100644 index 00000000..1168d405 --- /dev/null +++ b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-object-author-shape.md @@ -0,0 +1,9 @@ +--- +id: post-object-author +title: Post With Object Author +author: + id: known-author +--- + +Objects are not supported as relation values; refs should be scalar IDs or +arrays of scalar IDs. diff --git a/packages/core/src/providers/test/fixtures/missing-refs/tags/known-tag.md b/packages/core/src/providers/test/fixtures/missing-refs/tags/known-tag.md new file mode 100644 index 00000000..705c6035 --- /dev/null +++ b/packages/core/src/providers/test/fixtures/missing-refs/tags/known-tag.md @@ -0,0 +1,7 @@ +--- +id: known-tag +name: Known Tag +--- + +A normalized `Tag` collection record so we can prove `refs: { tags: 'Tag' }` +catches missing target ids the same way scalar relations do. diff --git a/packages/core/src/providers/test/fixtures/required-fields/authors/missing-id.md b/packages/core/src/providers/test/fixtures/required-fields/authors/missing-id.md new file mode 100644 index 00000000..89545d5b --- /dev/null +++ b/packages/core/src/providers/test/fixtures/required-fields/authors/missing-id.md @@ -0,0 +1,5 @@ +--- +name: Missing ID Author +--- + +Flatbread requires every record to expose an `id`. diff --git a/packages/core/src/providers/test/references.test.ts b/packages/core/src/providers/test/references.test.ts new file mode 100644 index 00000000..8535c4c5 --- /dev/null +++ b/packages/core/src/providers/test/references.test.ts @@ -0,0 +1,325 @@ +import test from 'ava'; +import filesystem from '@flatbread/source-filesystem'; +import markdownTransformer from '@flatbread/transformer-markdown'; +import { FlatbreadProvider } from '../base'; + +function missingRefsProject( + path = 'packages/core/src/providers/test/fixtures/missing-refs' +) { + return new FlatbreadProvider({ + source: filesystem(), + transformer: markdownTransformer(), + content: [ + { + path: `${path}/authors`, + collection: 'Author', + }, + { + path: `${path}/tags`, + collection: 'Tag', + }, + { + path: `${path}/posts`, + collection: 'Post', + refs: { + author: 'Author', + authors: 'Author', + tags: 'Tag', + }, + }, + ], + }); +} + +function manyToManyProject() { + const path = + 'packages/core/src/providers/test/fixtures/cardinality-many-to-many'; + + return new FlatbreadProvider({ + source: filesystem(), + transformer: markdownTransformer(), + content: [ + { + path: `${path}/posts`, + collection: 'Post', + refs: { + tags: 'Tag', + }, + }, + { + path: `${path}/tags`, + collection: 'Tag', + refs: { + posts: 'Post', + }, + }, + ], + }); +} + +test.serial( + 'supports one-to-one and one-to-many relation cardinality', + async (t) => { + const flatbread = missingRefsProject( + 'packages/core/src/providers/test/fixtures/missing-refs-clean' + ); + + const result = await flatbread.query({ + source: ` + query RelationCardinality { + allPosts { + id + author { + id + } + authors { + id + } + tags { + id + } + } + } + `, + }); + + t.deepEqual(result.data, { + allPosts: [ + { + id: 'known-post', + author: { + id: 'known-author', + }, + authors: [ + { + id: 'known-author', + }, + ], + tags: [ + { + id: 'known-tag', + }, + ], + }, + ], + }); + } +); + +test.serial('supports explicit many-to-many list refs', async (t) => { + const flatbread = manyToManyProject(); + + const result = await flatbread.query({ + source: ` + query ManyToManyCardinality { + allPosts { + id + tags { + id + posts { + id + } + } + } + } + `, + }); + + t.deepEqual(result.data, { + allPosts: [ + { + id: 'known-post', + tags: [ + { + id: 'known-tag', + posts: [ + { + id: 'known-post', + }, + ], + }, + ], + }, + ], + }); +}); + +test.serial( + 'rejects missing array reference targets before schema use', + async (t) => { + const flatbread = missingRefsProject(); + + const error = await t.throwsAsync(() => + flatbread.query({ + source: ` + query MissingAuthorRef { + allPosts { + id + } + } + `, + }) + ); + + const message = error?.message ?? ''; + + t.regex(message, /Flatbread found \d+ broken reference/); + t.regex( + message, + /Post\.authors\[1\][\s\S]*has-missing-author\.md[\s\S]*ghost-author[\s\S]*Author/ + ); + } +); + +test.serial( + 'rejects missing array reference into a Tag collection', + async (t) => { + const flatbread = missingRefsProject(); + + const error = await t.throwsAsync(() => + flatbread.query({ + source: ` + query MissingTagRef { + allPosts { + id + } + } + `, + }) + ); + + const message = error?.message ?? ''; + + t.regex( + message, + /Post\.tags\[1\][\s\S]*has-missing-tag\.md[\s\S]*ghost-tag[\s\S]*Tag/ + ); + } +); + +test.serial( + 'rejects invalid scalar reference shape before schema use', + async (t) => { + const flatbread = missingRefsProject(); + + const error = await t.throwsAsync(() => + flatbread.query({ + source: ` + query BadAuthorShape { + allPosts { + id + } + } + `, + }) + ); + + const message = error?.message ?? ''; + + t.regex( + message, + /Post\.author[\s\S]*has-bad-author-shape\.md[\s\S]*invalid reference value[\s\S]*Author/ + ); + } +); + +test.serial('rejects object relation values before schema use', async (t) => { + const flatbread = missingRefsProject(); + + const error = await t.throwsAsync(() => + flatbread.query({ + source: ` + query BadObjectAuthorShape { + allPosts { + id + } + } + `, + }) + ); + + const message = error?.message ?? ''; + + t.regex( + message, + /Post\.author[\s\S]*has-object-author-shape\.md[\s\S]*invalid reference value[\s\S]*Author/ + ); +}); + +test.serial( + 'rejects nested array relation values before schema use', + async (t) => { + const flatbread = missingRefsProject(); + + const error = await t.throwsAsync(() => + flatbread.query({ + source: ` + query BadNestedAuthorsShape { + allPosts { + id + } + } + `, + }) + ); + + const message = error?.message ?? ''; + + t.regex( + message, + /Post\.authors\[0\][\s\S]*has-nested-authors-shape\.md[\s\S]*invalid reference value[\s\S]*Author/ + ); + } +); + +test.serial( + 'builds and queries cleanly when every reference resolves', + async (t) => { + const flatbread = missingRefsProject( + 'packages/core/src/providers/test/fixtures/missing-refs-clean' + ); + + const result = await flatbread.query({ + source: ` + query KnownPost { + allPosts { + id + author { + id + name + } + authors { + id + } + tags { + id + } + } + } + `, + }); + + t.is(result.errors, undefined); + t.deepEqual(result.data, { + allPosts: [ + { + id: 'known-post', + author: { + id: 'known-author', + name: 'Known Author', + }, + authors: [ + { + id: 'known-author', + }, + ], + tags: [ + { + id: 'known-tag', + }, + ], + }, + ], + }); + } +); diff --git a/packages/core/src/providers/test/snapshots/validationSnapshots.test.ts.md b/packages/core/src/providers/test/snapshots/validationSnapshots.test.ts.md new file mode 100644 index 00000000..50c700cb --- /dev/null +++ b/packages/core/src/providers/test/snapshots/validationSnapshots.test.ts.md @@ -0,0 +1,37 @@ +# Snapshot report for `packages/core/src/providers/test/validationSnapshots.test.ts` + +The actual snapshot is saved in `validationSnapshots.test.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## validation snapshot: missing references and invalid relation shapes + +> Snapshot 1 + + `Flatbread found 5 broken references:␊ + - Post.author (in /packages/core/src/providers/test/fixtures/missing-refs/posts/has-bad-author-shape.md, record id "post-bad-shape") has an invalid reference value for collection Author: reference value must be a non-empty string or finite number identifier.␊ + - Post.author (in /packages/core/src/providers/test/fixtures/missing-refs/posts/has-object-author-shape.md, record id "post-object-author") has an invalid reference value for collection Author: reference value must be a non-empty string or finite number identifier.␊ + - Post.authors[0] (in /packages/core/src/providers/test/fixtures/missing-refs/posts/has-nested-authors-shape.md, record id "post-nested-authors") has an invalid reference value for collection Author: reference value must be a non-empty string or finite number identifier.␊ + - Post.authors[1] (in /packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-author.md, record id "post-missing-author") references "ghost-author" but no record with that id exists in collection Author␊ + - Post.tags[1] (in /packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-tag.md, record id "post-missing-tag") references "ghost-tag" but no record with that id exists in collection Tag` + +## validation snapshot: unknown target collection + +> Snapshot 1 + + `Flatbread found 1 broken reference:␊ + - Post.author (in /packages/core/src/providers/test/fixtures/missing-refs-clean/posts/known-post.md, record id "known-post") declares a reference to collection MissingCollection, but no such collection is configured` + +## validation snapshot: duplicate normalized IDs + +> Snapshot 1 + + `Flatbread found 1 invalid record ID:␊ + - Author record id "123" is duplicated after normalization (/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/numeric.md) (/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/string.md)` + +## validation snapshot: required ID field + +> Snapshot 1 + + `Flatbread found 1 invalid record ID:␊ + - Author record id (/packages/core/src/providers/test/fixtures/required-fields/authors/missing-id.md) must be a non-empty string or finite number identifier.` diff --git a/packages/core/src/providers/test/snapshots/validationSnapshots.test.ts.snap b/packages/core/src/providers/test/snapshots/validationSnapshots.test.ts.snap new file mode 100644 index 00000000..e108ae55 Binary files /dev/null and b/packages/core/src/providers/test/snapshots/validationSnapshots.test.ts.snap differ diff --git a/packages/core/src/providers/test/validationSnapshots.test.ts b/packages/core/src/providers/test/validationSnapshots.test.ts new file mode 100644 index 00000000..81fe5dd4 --- /dev/null +++ b/packages/core/src/providers/test/validationSnapshots.test.ts @@ -0,0 +1,103 @@ +import test from 'ava'; +import type { ExecutionContext } from 'ava'; +import filesystem from '@flatbread/source-filesystem'; +import markdownTransformer from '@flatbread/transformer-markdown'; +import { FlatbreadProvider } from '../base'; + +function project(path: string, refs: Record = {}) { + return new FlatbreadProvider({ + source: filesystem(), + transformer: markdownTransformer(), + content: [ + { + path: `${path}/authors`, + collection: 'Author', + }, + { + path: `${path}/tags`, + collection: 'Tag', + }, + { + path: `${path}/posts`, + collection: 'Post', + refs, + }, + ], + }); +} + +function authorOnlyProject(path: string) { + return new FlatbreadProvider({ + source: filesystem(), + transformer: markdownTransformer(), + content: [ + { + path: `${path}/authors`, + collection: 'Author', + }, + ], + }); +} + +async function validationMessage( + t: ExecutionContext, + flatbread: FlatbreadProvider +): Promise { + const error = await t.throwsAsync(() => + flatbread.query({ + source: ` + query ValidationSnapshot { + allAuthors { + id + } + } + `, + }) + ); + + return normalizeMessage(error?.message ?? ''); +} + +function normalizeMessage(message: string): string { + return message.split(process.cwd()).join(''); +} + +test('validation snapshot: missing references and invalid relation shapes', async (t) => { + const flatbread = project( + 'packages/core/src/providers/test/fixtures/missing-refs', + { + author: 'Author', + authors: 'Author', + tags: 'Tag', + } + ); + + t.snapshot(await validationMessage(t, flatbread)); +}); + +test('validation snapshot: unknown target collection', async (t) => { + const flatbread = project( + 'packages/core/src/providers/test/fixtures/missing-refs-clean', + { + author: 'MissingCollection', + } + ); + + t.snapshot(await validationMessage(t, flatbread)); +}); + +test('validation snapshot: duplicate normalized IDs', async (t) => { + const flatbread = authorOnlyProject( + 'packages/core/src/providers/test/fixtures/id-semantics-duplicates' + ); + + t.snapshot(await validationMessage(t, flatbread)); +}); + +test('validation snapshot: required ID field', async (t) => { + const flatbread = authorOnlyProject( + 'packages/core/src/providers/test/fixtures/required-fields' + ); + + t.snapshot(await validationMessage(t, flatbread)); +}); diff --git a/packages/core/src/resolvers/arguments.ts b/packages/core/src/resolvers/arguments.ts index 9c727db0..19230cd8 100644 --- a/packages/core/src/resolvers/arguments.ts +++ b/packages/core/src/resolvers/arguments.ts @@ -5,6 +5,7 @@ import sift, { } from '../utils/sift'; import { ContentNode, FlatbreadConfig } from '../types'; import { FlatbreadProvider } from '../providers/base'; +import { getNodeIdentifier, normalizeIdentifier } from '../utils/ids'; interface ResolveQueryArgsOptions { type: { name: string; @@ -13,22 +14,32 @@ interface ResolveQueryArgsOptions { }; } +interface QueryArgs { + filter?: Record; + limit?: number; + order?: 'ASC' | 'DESC'; + skip?: number; + sortBy?: string; + [key: string]: unknown; +} + /** * Resolvers for query arguments. */ const resolveQueryArgs = async ( - nodes: any[], - args: any, + nodes: ContentNode[], + args: QueryArgs, config: FlatbreadConfig, options: ResolveQueryArgsOptions -) => { +): Promise => { const { skip, limit, order, sortBy, filter } = args; if (filter) { // Place the nodes into a keyed object by ID so we can easily filter by ID without doing tons of looping. // TODO: store all nodes in an ID-keyed object. - // TODO: replace id field with user-defined/fallback identifier field. - const nodeById = keyBy(nodes, 'id'); + const nodeById = keyBy(nodes, (node: ContentNode) => + getNodeIdentifier(node, options.type.name) + ); // Turn the filter into a GraphQL subquery that returns an array of matching content node IDs. const listOfNodeIDsToFilter = await resolveFilter(filter, config, options); @@ -119,10 +130,10 @@ function buildFilterQueryFragment(filterSetManifest: TargetAndComparator) { * @param filter the filter argument */ export const resolveFilter = async ( - filter: Record, + filter: Record, config: FlatbreadConfig, options: ResolveQueryArgsOptions -): Promise<(string | number)[]> => { +): Promise => { // Seperate the filter into its parts: // - the path leading to the field we want to compare // - the comparator expression. @@ -134,7 +145,6 @@ export const resolveFilter = async ( // Build a GraphQL query fragment that will be used to resolve content nodes in a structure expected by the sift function, for the given filter. const filterQueryFragment = buildFilterQueryFragment(filterSetManifest); - // TODO: replace id field with user-defined/fallback identifier field const queryString = ` query ${options.type.pluralQueryName}_FilterSubquery { ${options.type.pluralQueryName} { @@ -150,7 +160,14 @@ export const resolveFilter = async ( const result = data?.[options.type.pluralQueryName] as ContentNode[]; - return result.filter(sift(filter)).map((node) => node.id); + return result + .filter(sift(filter)) + .map((node) => + normalizeIdentifier( + node.id, + `${options.type.name} filter subquery result id` + ) + ); }; /** @@ -159,15 +176,15 @@ export const resolveFilter = async ( * @param sortBy the field to sort by * @param nodes the array of nodes to sort */ -export const resolveSortBy = (sortBy: string, nodes: any[]): void => { - nodes.sort((nodeA: { [x: string]: any }, nodeB: { [x: string]: any }) => { +export const resolveSortBy = (sortBy: string, nodes: ContentNode[]): void => { + nodes.sort((nodeA, nodeB) => { const fieldA = nodeA[sortBy]; const fieldB = nodeB[sortBy]; - if (fieldA < fieldB) { + if (isSortable(fieldA) && isSortable(fieldB) && fieldA < fieldB) { return -1; } - if (fieldA > fieldB) { + if (isSortable(fieldA) && isSortable(fieldB) && fieldA > fieldB) { return 1; } // fields must be equal @@ -175,4 +192,12 @@ export const resolveSortBy = (sortBy: string, nodes: any[]): void => { }); }; +function isSortable(value: unknown): value is string | number | boolean { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ); +} + export default resolveQueryArgs; diff --git a/packages/core/src/types.test.ts b/packages/core/src/types.test.ts new file mode 100644 index 00000000..00aef624 --- /dev/null +++ b/packages/core/src/types.test.ts @@ -0,0 +1,71 @@ +import test from 'ava'; +import type { + Content, + ContentEntry, + ContentNode, + EntryNode, + IdentifierField, + Override, + Source, +} from './types'; +import type { VFile } from 'vfile'; + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B + ? 1 + : 2 + ? true + : false; + +type Assert = T; + +type ContentEntryRefsAreTyped = Assert< + Equal, Record> +>; + +type ContentNodeKeepsUnknownFields = Assert< + Equal +>; +type SourceFetchUsesContent = Assert< + Equal[0], Content> +>; +type SourceFetchByTypeReturnsVFiles = Assert< + Equal>, Promise> +>; +type ContentNodeIdUsesIdentifierField = Assert< + Equal +>; +type OverrideResolveReturnsUnknown = Assert< + Equal, unknown> +>; + +test('core public content types expose narrowed relation surfaces', (t) => { + const entry: ContentEntry = { + collection: 'Post', + refs: { + author: 'Author', + }, + }; + + const node: ContentNode = { + id: 'post-one', + customField: 'value', + }; + + const untypedEntry: EntryNode = { + customField: 'value', + }; + + // @ts-expect-error EntryNode values are unknown until narrowed. + const unsafeString: string = untypedEntry.customField; + + t.is(entry.refs?.author, 'Author'); + t.is(node.customField, 'value'); + t.is(unsafeString, 'value'); +}); + +void (0 as unknown as ContentEntryRefsAreTyped); +void (0 as unknown as ContentNodeKeepsUnknownFields); +void (0 as unknown as SourceFetchUsesContent); +void (0 as unknown as SourceFetchByTypeReturnsVFiles); +void (0 as unknown as ContentNodeIdUsesIdentifierField); +void (0 as unknown as OverrideResolveReturnsUnknown); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2b28b649..a8811041 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -9,8 +9,8 @@ export type CodegenOptions = { outputDir?: string; outputFile?: string; plugins?: string[]; - codegenConfig?: Record; - pluginConfig?: Record>; + codegenConfig?: Record; + pluginConfig?: Record>; watch?: boolean; cache?: boolean; documents?: string[]; @@ -29,9 +29,9 @@ export type BaseContentNode = { id: IdentifierField; }; -export type ContentNode = BaseContentNode & { - [key: string]: unknown; -}; +export type ContentNode< + TFields extends Record = Record +> = BaseContentNode & TFields; /** * Flatbread's configuration interface. @@ -76,7 +76,7 @@ export interface Transformer { * @param input Node to transform */ parse?: (input: VFile) => EntryNode; - preknownSchemaFragments?: () => Record; + preknownSchemaFragments?: () => Record; inspect: (input: EntryNode) => string; extensions: string[]; } @@ -86,7 +86,17 @@ export type TransformerPlugin = (config?: Config) => Transformer; /** * A representation of the content of a flat file. */ -export type EntryNode = Record; +export type EntryNode = Record; + +export interface ContentEntry< + TRefs extends Record = Record +> { + collection: string; + path?: string; + refs?: TRefs; + overrides?: Override[]; + [key: string]: unknown; +} /** * The result of an invoked `Source` plugin which contains methods on how to retrieve content nodes in @@ -94,13 +104,13 @@ export type EntryNode = Record; */ export interface Source { initialize?: (flatbreadConfig: LoadedFlatbreadConfig) => void; - fetchByType?: (path: string) => Promise; - fetch: ( - allContentTypes: Record[] - ) => Promise>; + fetchByType?: (path: string) => Promise; + fetch: (allContentTypes: Content) => Promise>; } -export type SourcePlugin = (sourceConfig?: Record) => Source; +export type SourcePlugin< + TConfig extends Record = Record +> = (sourceConfig?: TConfig) => Source; /** * An override can be used to declare a custom resolve for a field in content @@ -112,9 +122,13 @@ export interface Override { args?: GraphQLFieldConfigArgumentMap; description?: Maybe; resolve: ( - data: any, - extended: { source: any; context: any; args: any } - ) => any; + data: unknown, + extended: { + source: unknown; + context: unknown; + args: Record; + } + ) => unknown; } /** @@ -122,8 +136,4 @@ export interface Override { * * This is paired with a `Source` (and, *optionally*, a `Transformer`) plugin. */ -export type Content = { - collection: string; - overrides?: Override[]; - [key: string]: any; -}[]; +export type Content = ContentEntry[]; diff --git a/packages/core/src/utils/deepEntries.ts b/packages/core/src/utils/deepEntries.ts index 02198ed1..fada8d07 100644 --- a/packages/core/src/utils/deepEntries.ts +++ b/packages/core/src/utils/deepEntries.ts @@ -9,18 +9,18 @@ import typeOf from './typeOf'; * @returns a tuple with a path array and value which that path leads to */ const deepEntries = ( - obj: Record, + obj: unknown, path: string[] = [], - stack: any[] = [] -): [string[], any] => { + stack: [string[], unknown][] = [] +): [string[], unknown][] => { if (typeOf(obj) === 'object') { - for (let [key, value] of Object.entries(obj)) { + for (let [key, value] of Object.entries(obj as Record)) { stack = deepEntries(value, [...path, key], stack); } } else { stack.push([path, obj]); } - return stack as [string[], any]; + return stack; }; export default deepEntries; diff --git a/packages/core/src/utils/fieldOverrides.ts b/packages/core/src/utils/fieldOverrides.ts index f3106bb9..a389c826 100644 --- a/packages/core/src/utils/fieldOverrides.ts +++ b/packages/core/src/utils/fieldOverrides.ts @@ -1,6 +1,8 @@ import { FlatbreadConfig, Override } from '../types'; import { get, set } from 'lodash-es'; +type FieldOverrideTree = Record; + /** * Get an object containing functions nested in an object structure * aligning to the listed overrides in the config @@ -16,7 +18,7 @@ export function getFieldOverrides(collection: string, config: FlatbreadConfig) { if (!content?.overrides) return {}; const overrides = content.overrides; - return overrides.reduce((fields: any, override: Override) => { + return overrides.reduce((fields: FieldOverrideTree, override: Override) => { const { field, type, ...rest } = override; let path = field.replace(/\[\]/g, '[0]'); const endsWithArray = path.endsWith('[0]'); @@ -27,7 +29,11 @@ export function getFieldOverrides(collection: string, config: FlatbreadConfig) { set(fields, path, () => ({ type: endsWithArray ? `[${override.type}]` : override.type, ...rest, - resolve: (source: any, context: any, args: any) => { + resolve: ( + source: unknown, + context: unknown, + args: Record + ) => { return override.resolve(get(source, getPath), { source, context, diff --git a/packages/core/src/utils/ids.ts b/packages/core/src/utils/ids.ts new file mode 100644 index 00000000..c6a1cd5d --- /dev/null +++ b/packages/core/src/utils/ids.ts @@ -0,0 +1,70 @@ +import { EntryNode, IdentifierField } from '../types'; + +export type NormalizedIdentifier = string; + +/** + * Normalize a Flatbread record or relation identifier to the single comparison + * form used by internal resolvers and GraphQL query arguments. + */ +export function normalizeIdentifier( + value: unknown, + context = 'ID' +): NormalizedIdentifier { + if (typeof value === 'string') { + const normalized = value.trim(); + if (normalized.length > 0) { + return normalized; + } + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + + throw new Error( + `${context} must be a non-empty string or finite number identifier.` + ); +} + +/** + * Normalize a GraphQL/query argument identifier if it was supplied. Flatbread's + * GraphQL ID arguments are optional today, so omitted IDs should preserve the + * existing "no match" behavior rather than throwing. + */ +export function normalizeOptionalIdentifier( + value: unknown, + context = 'ID' +): NormalizedIdentifier | undefined { + if (value === null || value === undefined) { + return undefined; + } + + return normalizeIdentifier(value, context); +} + +/** + * Return a content node's normalized identifier with collection-aware context + * for diagnostics. + */ +export function getNodeIdentifier( + node: EntryNode, + collection: string +): NormalizedIdentifier { + return normalizeIdentifier( + node.id, + `${collection} record id${sourceContext(node)}` + ); +} + +export function isIdentifierField(value: unknown): value is IdentifierField { + try { + normalizeIdentifier(value); + return true; + } catch { + return false; + } +} + +function sourceContext(node: EntryNode): string { + return typeof node._path === 'string' ? ` (${node._path})` : ''; +} diff --git a/packages/core/src/utils/references.ts b/packages/core/src/utils/references.ts new file mode 100644 index 00000000..449cc07a --- /dev/null +++ b/packages/core/src/utils/references.ts @@ -0,0 +1,183 @@ +import { Content, EntryNode } from '../types'; +import { getNodeIdentifier, normalizeIdentifier } from './ids'; + +/** + * Validate that every relation declared via a collection's `refs` config + * resolves to a record that actually exists in the target collection. + * + * Runs after content transforms and ID normalization so it can rely on the + * same normalized identifier semantics that the GraphQL resolvers and ID + * validation use. Like ID validation, it aggregates every problem it finds + * and throws a single error before the schema is built so consumers see all + * broken edges before query-time surprises. + */ +export function validateCollectionReferences( + allContentNodesJSON: Record, + content: Content +): void { + const idsByCollection = collectIdsByCollection(allContentNodesJSON); + const errors: string[] = []; + + for (const collectionConfig of content) { + const collection = String(collectionConfig.collection); + const refs = collectionConfig.refs as Record | undefined; + if (!refs) continue; + + const nodes = allContentNodesJSON[collection]; + if (!nodes) continue; + + for (const node of nodes) { + for (const [refField, target] of Object.entries(refs)) { + const targetCollection = String(target); + const value = (node as Record)[refField]; + + if (value === null || value === undefined) continue; + + const targetIds = idsByCollection.get(targetCollection); + + if (Array.isArray(value)) { + value.forEach((entry, index) => { + const failure = checkReference(entry, targetIds); + if (failure) { + errors.push( + formatDiagnostic({ + collection, + node, + refField: `${refField}[${index}]`, + targetCollection, + failure, + }) + ); + } + }); + } else { + const failure = checkReference(value, targetIds); + if (failure) { + errors.push( + formatDiagnostic({ + collection, + node, + refField, + targetCollection, + failure, + }) + ); + } + } + } + } + } + + if (errors.length > 0) { + errors.sort(); + throw new Error( + `Flatbread found ${errors.length} broken reference${ + errors.length === 1 ? '' : 's' + }:\n${errors.map((message) => `- ${message}`).join('\n')}` + ); + } +} + +type ReferenceFailure = + | { kind: 'missing'; missingId: string } + | { kind: 'unknownTarget' } + | { kind: 'invalidShape'; reason: string }; + +function checkReference( + value: unknown, + targetIds: Set | undefined +): ReferenceFailure | undefined { + let normalized: string; + try { + normalized = normalizeIdentifier(value, 'reference value'); + } catch (error) { + return { + kind: 'invalidShape', + reason: error instanceof Error ? error.message : String(error), + }; + } + + if (!targetIds) { + return { kind: 'unknownTarget' }; + } + + if (!targetIds.has(normalized)) { + return { kind: 'missing', missingId: normalized }; + } + + return undefined; +} + +interface DiagnosticInput { + collection: string; + node: EntryNode; + refField: string; + targetCollection: string; + failure: ReferenceFailure; +} + +function formatDiagnostic({ + collection, + node, + refField, + targetCollection, + failure, +}: DiagnosticInput): string { + const fieldPath = `${collection}.${refField}`; + const recordContext = describeRecord(node); + + switch (failure.kind) { + case 'missing': + return `${fieldPath}${recordContext} references "${failure.missingId}" but no record with that id exists in collection ${targetCollection}`; + case 'unknownTarget': + return `${fieldPath}${recordContext} declares a reference to collection ${targetCollection}, but no such collection is configured`; + case 'invalidShape': + return `${fieldPath}${recordContext} has an invalid reference value for collection ${targetCollection}: ${failure.reason}`; + } +} + +function describeRecord(node: EntryNode): string { + const parts: string[] = []; + + if (typeof node._path === 'string' && node._path.length > 0) { + parts.push(node._path); + } + + let recordId: string | undefined; + try { + recordId = normalizeIdentifier(node.id); + } catch { + recordId = undefined; + } + + if (recordId !== undefined) { + parts.push(`record id "${recordId}"`); + } + + if (parts.length === 0) { + return ''; + } + + return ` (in ${parts.join(', ')})`; +} + +function collectIdsByCollection( + allContentNodesJSON: Record +): Map> { + const idsByCollection = new Map>(); + + for (const [collection, nodes] of Object.entries(allContentNodesJSON)) { + const ids = new Set(); + for (const node of nodes) { + try { + ids.add(getNodeIdentifier(node, collection)); + } catch { + // Invalid ids are surfaced by validateCollectionIdentifiers; skip + // them here so the missing-ref pass can still report what it can. + } + } + idsByCollection.set(collection, ids); + } + + return idsByCollection; +} diff --git a/packages/core/src/utils/sift.ts b/packages/core/src/utils/sift.ts index 05b1b896..ba428259 100644 --- a/packages/core/src/utils/sift.ts +++ b/packages/core/src/utils/sift.ts @@ -3,6 +3,7 @@ import { get } from 'lodash-es'; import deepEntries from './deepEntries'; import reduceBooleans from './reduceBooleans'; import { isMatch as isWildcardMatch } from 'matcher'; +import { normalizeIdentifier } from './ids'; /** * Return a callable sifting function that can be used to filter an array of objects with the given filter object. @@ -31,8 +32,16 @@ const createFilterFunction = ( for (let { path, comparator } of filterSetManifest) { // Retrieve the value of interest from the node. const needle = get(node, path, undefined); + const comparisonNeedle = shouldNormalizeIdComparator(path, comparator) + ? normalizeSiftId(needle, 'filter id value', true) + : needle; + const comparisonComparator = shouldNormalizeIdComparator(path, comparator) + ? normalizeIdComparator(comparator) + : comparator; // Compare the value of interest to the target value, and store the result of the evaluated expression. - evaluatedFilterSet.push(generateComparisonFunction(comparator)(needle)); + evaluatedFilterSet.push( + generateComparisonFunction(comparisonComparator)(comparisonNeedle) + ); } // Combine the filter set results with the union operation. @@ -41,6 +50,145 @@ const createFilterFunction = ( }; export default createFilterFunction; +function normalizeSiftId( + value: unknown, + context: string, + allowMissing = false +): unknown { + if (allowMissing && (value === null || value === undefined)) { + return value; + } + + return normalizeIdentifier(value, context); +} + +function normalizeIdComparator(comparator: Comparator): Comparator { + const { operation, value } = comparator; + + if (operation === 'exists' || operation === 'strictlyExists') { + return comparator; + } + + if (Array.isArray(value)) { + return { + operation, + value: value.map((item) => + normalizeSiftId(item, `filter id comparator "${operation}"`) + ), + }; + } + + return { + operation, + value: normalizeSiftId(value, `filter id comparator "${operation}"`), + }; +} + +function assertArrayComparator( + value: unknown, + operation: ComparatorOperation +): readonly unknown[] { + if (!Array.isArray(value)) { + throw new Error(`Comparator "${operation}" requires an array value.`); + } + + return value; +} + +function assertRegExpComparator( + value: unknown, + operation: ComparatorOperation +): RegExp { + if (!(value instanceof RegExp)) { + throw new Error(`Comparator "${operation}" requires a RegExp value.`); + } + + return value; +} + +function includesValue( + source: unknown, + value: unknown, + operation: ComparatorOperation +): boolean { + if (Array.isArray(source)) { + return source.includes(value); + } + + if (typeof source === 'string') { + if (value instanceof RegExp) { + throw new TypeError( + 'First argument to String.prototype.includes must not be a regular expression' + ); + } + return Reflect.apply(String.prototype.includes, source, [value]); + } + + throw new Error( + `Comparator "${operation}" requires an array or string field.` + ); +} + +function matchesRegExp(source: unknown, value: RegExp): boolean { + if (typeof source === 'string') { + return value.test(source); + } + + if (Array.isArray(source)) { + return source.some((item) => typeof item === 'string' && value.test(item)); + } + + throw new Error('Comparator "regex" requires an array or string field.'); +} + +function matchesWildcard( + source: unknown, + patterns: string | readonly string[] +): boolean { + if (typeof source === 'string') { + return isWildcardMatch(source, patterns); + } + + if (Array.isArray(source)) { + return source.some( + (item) => typeof item === 'string' && isWildcardMatch(item, patterns) + ); + } + + throw new Error('Comparator "wildcard" requires an array or string field.'); +} + +function assertWildcardPatternValue( + value: unknown, + operation: ComparatorOperation +): string | readonly string[] { + if (typeof value === 'string') { + return value; + } + + if ( + Array.isArray(value) && + value.every((item): item is string => typeof item === 'string') + ) { + return value; + } + + throw new Error( + `Comparator "${operation}" requires a string or string array pattern value.` + ); +} + +function shouldNormalizeIdComparator( + path: string[], + comparator: Comparator +): boolean { + if (path.length !== 1 || path[0] !== 'id') { + return false; + } + + return ['eq', 'ne', 'in', 'nin'].includes(comparator.operation); +} + /** * Generate a comparison function that can be used to compare a variable `a` (the field in each node) to a constant value `value` (target value in filter argument). * @@ -53,33 +201,45 @@ function generateComparisonFunction( const { operation, value } = comparator; switch (operation) { case 'eq': - return (a: any) => a === value; + return (a: unknown) => a === value; case 'ne': - return (a: any) => a !== value; - case 'lt': - return (a: any) => a < value; - case 'lte': - return (a: any) => a <= value; - case 'gt': - return (a: any) => a > value; - case 'gte': - return (a: any) => a >= value; - case 'in': - return (a: any) => value.includes(a); - case 'nin': - return (a: any) => !value.includes(a); + return (a: unknown) => a !== value; + case 'lt': { + return (a: unknown) => compareComparable(a, value, (l, r) => l < r); + } + case 'lte': { + return (a: unknown) => compareComparable(a, value, (l, r) => l <= r); + } + case 'gt': { + return (a: unknown) => compareComparable(a, value, (l, r) => l > r); + } + case 'gte': { + return (a: unknown) => compareComparable(a, value, (l, r) => l >= r); + } + case 'in': { + const list = assertArrayComparator(value, operation); + return (a: unknown) => list.includes(a); + } + case 'nin': { + const list = assertArrayComparator(value, operation); + return (a: unknown) => !list.includes(a); + } case 'includes': - return (a: any) => a.includes(value); + return (a: unknown) => includesValue(a, value, operation); case 'excludes': - return (a: any) => !a.includes(value); - case 'regex': - return (a: any) => value.test(a); - case 'wildcard': - return (a: any) => isWildcardMatch(a, value); + return (a: unknown) => !includesValue(a, value, operation); + case 'regex': { + const pattern = assertRegExpComparator(value, operation); + return (a: unknown) => matchesRegExp(a, pattern); + } + case 'wildcard': { + const patterns = assertWildcardPatternValue(value, operation); + return (a: unknown) => matchesWildcard(a, patterns); + } case 'exists': - return (a: any) => (value ? a != undefined : a == undefined); + return (a: unknown) => (value ? a != undefined : a == undefined); case 'strictlyExists': - return (a: any) => (value ? a !== undefined : a === undefined); + return (a: unknown) => (value ? a !== undefined : a === undefined); default: throw new Error(`Unsupported operation: ${operation}`); } @@ -96,6 +256,9 @@ export const generateFilterSetManifest = ( ): TargetAndComparator => { return deepEntries(filterArgs).map(([path, value]) => { const operation = path.pop(); + if (!isComparatorOperation(operation)) { + throw new Error(`Unsupported operation: ${String(operation)}`); + } return { path, @@ -107,12 +270,36 @@ export const generateFilterSetManifest = ( }); }; +function isComparatorOperation( + operation: unknown +): operation is ComparatorOperation { + return ( + typeof operation === 'string' && + [ + 'eq', + 'ne', + 'lt', + 'lte', + 'gt', + 'gte', + 'in', + 'nin', + 'includes', + 'excludes', + 'regex', + 'wildcard', + 'exists', + 'strictlyExists', + ].includes(operation) + ); +} + /** * The filter argument object using a MongoDB-like syntax, inspired by how Gatsby does it. * * @see [Gatsby's query filters](https://github.com/gatsbyjs/gatsby/blob/d56c1f12ad2b3e7fa245f4ff9a74e81d0585b79e/docs/docs/query-filters.md) for API details. */ -type SiftArgs = Record; +type SiftArgs = Record; /** * An array of target and comparator objects @@ -124,7 +311,7 @@ export type TargetAndComparator = { path: string[]; comparator: Comparator }[]; */ type Comparator = { operation: ComparatorOperation; - value: any; + value: unknown; }; /** @@ -167,4 +354,27 @@ type ComparatorOperation = /** * Compare a value to a constant target value. */ -type CompareValueAgainstConstant = (a: any) => boolean; +type CompareValueAgainstConstant = (a: unknown) => boolean; + +type Comparable = string | number | boolean; + +function compareComparable( + left: unknown, + right: unknown, + compare: (left: Comparable, right: Comparable) => boolean +): boolean { + if (!isComparable(left) || !isComparable(right)) { + return false; + } + + // Preserve native `<`/`<=`/`>`/`>=` coercion across mixed primitives (e.g. numeric field vs `'1'`). + return compare(left, right); +} + +function isComparable(value: unknown): value is Comparable { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ); +} diff --git a/packages/core/src/utils/tests/fieldOverrides.test.ts b/packages/core/src/utils/tests/fieldOverrides.test.ts index fca200ee..884c6cea 100644 --- a/packages/core/src/utils/tests/fieldOverrides.test.ts +++ b/packages/core/src/utils/tests/fieldOverrides.test.ts @@ -17,7 +17,7 @@ test('basic override', (t) => { }, }, ]) - ); + ) as any; t.snapshot(result); t.is(result.basic().resolve({ basic: 'test' }), true); }); @@ -34,7 +34,7 @@ test('nested basic override', (t) => { }, }, ]) - ); + ) as any; t.snapshot(result); t.is(result.nested.basic().resolve({ basic: 'test' }), true); }); @@ -51,7 +51,7 @@ test('basic array override', (t) => { }, }, ]) - ); + ) as any; t.snapshot(result); t.deepEqual(result.basic().resolve({ basic: [''] }), [true]); }); @@ -68,7 +68,7 @@ test('basic object array override', (t) => { }, }, ]) - ); + ) as any; t.snapshot(result); t.deepEqual(result.basic[0].obj().resolve({ obj: 'test' }), true); }); @@ -85,7 +85,7 @@ test('override with custom type', (t) => { }, }, ]) - ); + ) as any; t.snapshot(result); t.deepEqual(result.basic().resolve({ basic: 'test' }), true); }); diff --git a/packages/core/src/utils/tests/sift.test.ts b/packages/core/src/utils/tests/sift.test.ts index 767e53ee..ae8f68af 100644 --- a/packages/core/src/utils/tests/sift.test.ts +++ b/packages/core/src/utils/tests/sift.test.ts @@ -19,6 +19,31 @@ test('Sift for nodes with name equal to "foo"', (t) => { t.deepEqual(nodes.filter(sift({ name: { eq: 'foo' } })), [nodes[0]]); }); +test('Sift normalizes ID filters before strict comparison', (t) => { + t.deepEqual(nodes.filter(sift({ id: { eq: '1' } })), [nodes[0]]); +}); + +test('Sift rejects invalid ID filter comparators', (t) => { + t.throws(() => nodes.filter(sift({ id: { eq: '' } })), { + message: + 'filter id comparator "eq" must be a non-empty string or finite number identifier.', + }); +}); + +test('Sift supports wildcard and regex filters against string arrays', (t) => { + const taggedNodes = [ + { id: 1, tags: ['alpha', 'beta'] }, + { id: 2, tags: ['gamma'] }, + ]; + + t.deepEqual(taggedNodes.filter(sift({ tags: { wildcard: '*ta' } })), [ + taggedNodes[0], + ]); + t.deepEqual(taggedNodes.filter(sift({ tags: { regex: /^gam/ } })), [ + taggedNodes[1], + ]); +}); + test('Sift for nodes with nested object "child" having age greater than or equal to 18', (t) => { t.deepEqual(nodes.filter(sift({ child: { age: { gte: 18 } } })), [ nodes[0], @@ -69,3 +94,69 @@ test('Union sift for nodes with wildcard title matching "*tion", rating greater [nodes2[0]] ); }); + +test('Ordered comparators return no match for missing, null, non-primitive, or type-mismatched fields without throwing', (t) => { + const varied = [ + { id: 1, rank: 10 }, + { id: 2 }, + { id: 3, rank: null }, + { id: 4, rank: {} as unknown }, + { id: 5, rank: 'nine' }, + ]; + + t.notThrows(() => varied.filter(sift({ rank: { gte: 5 } }))); + t.deepEqual(varied.filter(sift({ rank: { gte: 5 } })), [varied[0]]); + + t.notThrows(() => [{ id: 1, rank: 10 }].filter(sift({ rank: { gt: '1' } }))); + t.deepEqual([{ id: 1, rank: 10 }].filter(sift({ rank: { gt: '1' } })), [ + { id: 1, rank: 10 }, + ]); + + t.deepEqual( + [ + { id: 1, n: 2 }, + { id: 2, n: 10 }, + ].filter(sift({ n: { lt: '9' } })), + [{ id: 1, n: 2 }] + ); + + const withBool = [ + { id: 1, flag: true }, + { id: 2, flag: false }, + ]; + t.deepEqual(withBool.filter(sift({ flag: { eq: true } })), [withBool[0]]); + t.deepEqual(withBool.filter(sift({ flag: { gt: false } })), [withBool[0]]); +}); + +test('String includes/excludes coerce the comparator value like String.prototype.includes', (t) => { + const items = [ + { id: 1, title: 'post 123' }, + { id: 2, title: 'hello' }, + ]; + + t.notThrows(() => items.filter(sift({ title: { includes: 123 } }))); + t.deepEqual(items.filter(sift({ title: { includes: 123 } })), [items[0]]); + t.deepEqual(items.filter(sift({ title: { excludes: 123 } })), [items[1]]); +}); + +test('String includes/excludes reject RegExp search values like String.prototype.includes', (t) => { + const row = { id: 1, title: 'abc' }; + + t.throws(() => [row].filter(sift({ title: { includes: /a/ } })), { + instanceOf: TypeError, + message: + 'First argument to String.prototype.includes must not be a regular expression', + }); + t.throws(() => [row].filter(sift({ title: { excludes: /a/ } })), { + instanceOf: TypeError, + message: + 'First argument to String.prototype.includes must not be a regular expression', + }); +}); + +test('Ordered comparator on sparse nested paths does not throw and skips non-matching nodes', (t) => { + const rows = [{ id: 1 }, { id: 2, meta: { score: 5 } }]; + + t.notThrows(() => rows.filter(sift({ meta: { score: { gte: 3 } } }))); + t.deepEqual(rows.filter(sift({ meta: { score: { gte: 3 } } })), [rows[1]]); +}); diff --git a/packages/core/src/utils/transformKeys.ts b/packages/core/src/utils/transformKeys.ts index d33c4f4e..618cbc53 100644 --- a/packages/core/src/utils/transformKeys.ts +++ b/packages/core/src/utils/transformKeys.ts @@ -17,14 +17,17 @@ class IllegalFieldNameError extends Error { } } -function isObject(obj: any): obj is Object { - return obj != null && obj.constructor.name === 'Object'; +function isObject(obj: unknown): obj is Record { + return ( + obj != null && + (obj as { constructor?: { name?: string } }).constructor?.name === 'Object' + ); } export default function transformKeys( - obj: any, + obj: unknown, transform: (key: string) => string -): any { +): unknown { if (Array.isArray(obj)) return obj.map((item) => transformKeys(item, transform)); if (!isObject(obj)) return obj; diff --git a/packages/flatbread/README.md b/packages/flatbread/README.md index ef54d87c..73fbfc36 100644 --- a/packages/flatbread/README.md +++ b/packages/flatbread/README.md @@ -16,116 +16,232 @@

-Eat your relational markdown data _and query it, too,_ with [GraphQL](https://graphql.org/) inside damn near any framework (statement awaiting peer-review). +Turn flat files in Git into typed, relational content for your TypeScript app. **[GraphQL](https://graphql.org/)** and codegen are a common **read interface** for that content graph—not the only surface you can build; see [docs/positioning.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/positioning.md). + +**Flatbread** is a Git-native relational flat-file content layer for TypeScript apps. Your repo and filesystem are the source of truth; plugins (sources, transformers, and resolvers) extend how content is loaded and shaped. + +**Who it's for:** Teams shipping TypeScript sites, internal tools, and starters who want **versioned, reviewable content** and **relationships between entries**—without standing up a CMS database or giving up ownership of where content lives. + +**Non-goals:** + +- Not a hosted CMS, dashboard, or authoring UI: Flatbread is a library and local workflow, not a full content-management product you log into. +- Not a general-purpose GraphQL platform or a substitute for a general-purpose database (transactions, granular access control, and high-scale multi-writer workloads are out of scope). +- Reliable live reload of content while the dev server runs is [not a supported pillar yet](https://github.com/FlatbreadLabs/flatbread/issues/65); expect to restart to pick up file changes. + +**GraphQL:** In the default toolkit, GraphQL is a common **read interface** for the content graph—schema generation and codegen are how many apps reach the data, not the definition of the product. More detail: [docs/positioning.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/positioning.md). + +**Glossary:** Quick definitions for **collection**, **relation**, **ID**, **cardinality**, **validation**, **query interface**, and how the **generated GraphQL schema / operation types** map to those terms (GraphQL as one read path, not the whole product)—see [docs/glossary.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md). + +**Local dev loop:** Codegen watch, schema rebuild, content reload, and framework restart boundaries are documented in [docs/local-dev-loop.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/local-dev-loop.md). + +**Portability:** Stable JSON snapshot export is available as a core API and documented in [docs/json-export.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/json-export.md). + +**Ownership and exit:** Raw files, Git history, JSON/CSV exports, GraphQL introspection, and generated TypeScript all fit one portability story in [docs/data-ownership.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/data-ownership.md). + +**Roadmap:** Current keep/kill/iterate decisions from validation work live in [docs/roadmap.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/roadmap.md). For contributing to this monorepo, use Node 20.19+ with pnpm 10.33.x. Runtime support for published packages is tracked by each package's own metadata. Born out of a desire to [Gridsome](https://gridsome.org/) (or [Gatsby](https://www.gatsbyjs.com/)) anything, this project harnesses a plugin architecture to be easily customizable to fit your use cases. -# Install + Use +## Quickstart (posts, authors, and tags) -🚧 This project is currently experimental, and the API may change considerably before `v1.0`. Feel free to hop in and contribute some issues or PRs! +🚧 This project is experimental; the API may change before `v1.0`. -To use the most common setup for markdown files sourced from the filesystem, Flatbread interally ships with + exposes the [`source-filesystem`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/source-filesystem) + [`transformer-markdown`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/transformer-markdown) plugins. +This repo’s **canonical first success path** is the **Next.js example** (`examples/nextjs`). It reads shared markdown under **`examples/content`** (mounted in that app as `content/` via symlink). Commands below are exact for that layout. -The following example takes you through the default flatbread setup. +### 1 · What you are modeling -```bash -pnpm i flatbread@latest +- **Collections** (`Post`, `Author`) map to folders of files; see the [glossary](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md). +- **Relations:** posts declare `authors:` in frontmatter as a list of **author ids**; Flatbread resolves them through **`refs`** in config (same idea as joins, over files—**not** a remote database). +- **Tags:** in the bundled example, each post exposes **`tags`** as a **YAML string list** in frontmatter. That becomes a **`[String]`** field on **`Post`** in the generated schema. That is **facet-style metadata** repeated per post—not the same machinery as **`refs`** to another collection. If you need normalized tag **records** shared across posts, model a **`Tag`** collection and wire **`refs`** yourself (advanced). + +Illustrative frontmatter: + +```yaml +--- +id: your-post-id +title: Example +authors: + - author-id-one +tags: + - typescript + - content-graph +--- +``` + +Markdown **below** the closing `---` is the post body. + +### 2 · Content layout (this monorepo) + +From the repo root, the markdown that backs the relational story lives here: + +```text +examples/content/markdown/posts/ # Post collection (incl. example-post.md, …) +examples/content/markdown/authors/ # Author collection +``` + +The Next example points `flatbread.config.js` at `content/markdown/...` **relative to `examples/nextjs`**, where `content` is the symlink to `../content`. + +**Backing files for posts, authors, and tags (this example):** + +| What | Where it lives | Glossary terms | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Posts** | `examples/content/markdown/posts/*.md` — one **record** per file | [Collection](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#collection), [Record](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#record) | +| **Authors** | `examples/content/markdown/authors/*.md` — one **record** per file | Same; **IDs** in frontmatter wire **relations** | +| **Tags** | The `tags:` YAML list **in each post’s frontmatter** (facet metadata on that **Post**). There is **no** `markdown/tags/` directory here. | [Tag (facet) vs `Tag` collection](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#tag-facet-vs-tag-collection) | + +### Traceability: same relation model (files, config, query interface) + +The table below ties the **Git-native** model to the default **GraphQL** read layer without implying GraphQL is the product’s whole identity—GraphQL is one [**query interface**](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#query-interface); files and config remain the source of truth. + +| Layer | You see… | Glossary | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Files** | `authors:` ids in a post file match `id:` in author files; `tags:` is a string list on the post | [Relation](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#relation), [ID](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#id), [Tag (facet) vs `Tag` collection](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#tag-facet-vs-tag-collection) | +| **`flatbread.config.js`** | `content` entries with `collection: 'Post' \| 'Author'` and `refs: { authors: 'Author' }` | [Collection](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#collection), [Relation](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#relation) | +| **Generated GraphQL schema + codegen TS** | `allPosts { tags authors { id name } }` — **refs** resolve to **`Author`** objects; **`tags`** stays a scalar list on **`Post`** | [Generated schema and operation types (GraphQL)](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#generated-schema-and-operation-types-graphql), [Cardinality](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#cardinality) | + +**Illustrative query result** (same **relation model** as [`examples/content/markdown/posts/example-post.md`](https://github.com/FlatbreadLabs/flatbread/blob/main/examples/content/markdown/posts/example-post.md): authors `2a3e` / `40s3`, **tags** from frontmatter). Values are from that file and its resolved **authors**; the shape matches the **`GetPostsAuthorsAndTags`** operation in **§3** after you include **`tags`** and **`authors`** in your **`.graphql`** document (see also `queries/posts.graphql`, which you can extend the same way): + +```json +{ + "allPosts": [ + { + "id": "sdfsdf-23423-sdfsd-23444-dfghf", + "title": "The Art of Measuring Cats in Fruit Units", + "tags": ["cats", "measurements", "fruit-science", "important-research"], + "authors": [ + { "id": "2a3e", "name": "Tony" }, + { "id": "40s3", "name": "Eva" } + ] + } + ] +} ``` -Automatically create a `flatbread.config.js` file: +Add **`tags`** (and any other fields) to your **`.graphql`** documents and rerun codegen so operations and `generated/graphql.ts` stay aligned with the files—snippets in docs are **illustrative** until your checked-in queries match. + +### 3 · Run it from the repo root + +Prerequisites: **Node 20.19+**, **pnpm 10.33.x** (see [CONTRIBUTING.md](CONTRIBUTING.md)). ```bash -npx flatbread init +pnpm install +pnpm build +cd examples/nextjs +pnpm exec flatbread codegen --verbose ``` -> If you're lookin for different use cases, take a peek through the various [`packages`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages) to see if any of those plugins fit your needs. You can find the relevant usage API contained therein. - -Take this example where we have a content folder in our repo containing posts and author data: - -```gql -content/ -├─ posts/ -│ ├─ example-post.md -│ ├─ funky-monkey-friday.md -├─ authors/ -│ ├─ me.md -│ ├─ my-cat.md -... -flatbread.config.js -package.json +That writes **`generated/graphql.ts`**: TypeScript types and typed document nodes for your **`.graphql`** operations (configure globs under `codegen.documents` in `flatbread.config.js`). + +Add a `.graphql` file (see `queries/posts.graphql` in the example), then rerun **`pnpm exec flatbread codegen --verbose`** so the operation reflects **`tags`**, **`authors`**, etc. Illustrative operation you can paste into `queries/`: + +```graphql +query GetPostsAuthorsAndTags { + allPosts(limit: 5) { + id + title + tags + authors { + id + name + } + } + allAuthors { + id + name + } +} ``` -In reference to that structure, set up a `flatbread.config.js` in the root of your project: +After codegen, your app imports types from **`./generated/graphql`**. The **result shape** of that operation is typed (for example **`GetPostsAuthorsAndTagsQuery`**)—relations resolve to **`Author`** objects while **`tags`** stay a **string array** on **`Post`**, matching the file metadata—the same row as the [illustrative JSON](#traceability-same-relation-model-files-config-query-interface) under **Traceability**. + +The generated file also exposes a prototype **TypeScript read API** derived from the configured content model. In the Next.js example, [`examples/nextjs/lib/read.ts`](https://github.com/FlatbreadLabs/flatbread/blob/main/examples/nextjs/lib/read.ts) wires **`createFlatbreadReadApi()`** to the existing GraphQL fetcher and reads **posts**, **authors**, and **tags** with a generated default selection—no hand-written GraphQL document at the call site. + +#### Choosing a read interface + +Flatbread starts with **Git-native relational content** for TypeScript apps: flat files define records, frontmatter fields, ids, and refs; `flatbread.config.js` tells Flatbread how those files become typed collections. **GraphQL is one interface over that typed model**, and the generated TypeScript read API is another app-facing surface generated from the same model. + +Use **GraphQL operations** when your app needs explicit query documents, custom selections, Apollo or other GraphQL clients, persisted operations, or direct access to the GraphQL endpoint. Add `.graphql` documents, include fields like **`tags`** and **`authors`**, and rerun codegen so operation types such as **`GetPostsAuthorsAndTagsQuery`** match the posts/authors/tags graph. + +Use the prototype **generated TypeScript read API** when your app wants collection-shaped helpers for common reads from the configured Flatbread model, especially simple app reads such as posts, authors, tags, and resolved relations without writing GraphQL at each call site. The generated helpers currently execute through the GraphQL layer and still offer an experimental selection-string escape hatch, so both paths expose the same typed content graph backed by the same flat files while GraphQL remains the stable low-level interface. + +Default filesystem + markdown wiring uses the bundled [`source-filesystem`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/source-filesystem) and [`transformer-markdown`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/transformer-markdown) plugins (`flatbread` re-exports them). + +### 4 · Minimal relational config (mental model) + +The example’s production config loads extra collections for tests; **the core onboarding shape** is: ```js import { defineConfig, transformerMarkdown, sourceFilesystem } from 'flatbread'; -const transformerConfig = { - markdown: { - gfm: true, - externalLinks: true, - }, -}; export default defineConfig({ source: sourceFilesystem(), - transformer: transformerMarkdown(transformerConfig), - + transformer: transformerMarkdown({ + markdown: { gfm: true, externalLinks: true }, + }), content: [ { - path: 'content/posts', + path: 'content/markdown/posts', collection: 'Post', - refs: { - authors: 'Author', - }, + refs: { authors: 'Author' }, }, { - path: 'content/authors', + path: 'content/markdown/authors', collection: 'Author', - refs: { - friend: 'Author', - }, + refs: { friend: 'Author' }, }, ], }); ``` -Now hit your `package.json` and put the keys in the truck: +### 5 · Reading the graph: GraphQL (after the model exists) + +Flatbread builds a content **graph from files**. In the default toolchain, **GraphQL is one read interface**: schema + resolver shape over that graph—not “Flatbread is a GraphQL CMS.” + +Wire your framework so the CLI wraps dev/build (**`flatbread start`** passes through your command after **`--`**). There is **no** `flatbread dev` subcommand. ```js -// before -"scripts": { - "dev": "svelte-kit dev", - "build": "svelte-kit build", -}, - -// after becoming based and flatbread-pilled -"scripts": { - "dev": "flatbread start -- svelte-kit dev", - "build": "flatbread start -- svelte-kit build", -}, +// package.json scripts (adapt the part after `--` to your framework) +{ + "scripts": { + "dev": "flatbread start -- next dev --turbopack", + "build": "flatbread start -- next build" + } +} ``` -The Flatbread CLI will capture any script you add in after the `--` and appropriately unite them to live in a land of fairies and wonder while they dance into the sunset as you query your brand spankin new GraphQL server however you'd like from within your app. +In the Next example from **`examples/nextjs`**, **`pnpm dev`** enables HTTPS locally and pairs Next with Flatbread. The GraphQL HTTP endpoint defaults to **`http://localhost:5057/graphql`**; the Next app is on **`3000`**. **`pnpm run dev`** here is distinct from **`next start`** alone (production Next without Flatbread unless you arrange serving yourself). -## Run that shit 🏃‍♀️ +```bash +pnpm dev +``` + +If the server starts cleanly, Flatbread prints the **`graphql`** URL. Opening it launches Apollo Studio against the generated schema—you can iterate on queries there, then freeze them into **`.graphql`** files and rerun **`flatbread codegen`**. + +Live reload of markdown while the process runs is **[not reliable yet](https://github.com/FlatbreadLabs/flatbread/issues/65)**—restart dev after content changes. + +## Install Flatbread in your own repo + +Outside this monorepo: ```bash -pnpm run dev +pnpm add flatbread@latest ``` -## Construct queries 👩‍🍳 +Scaffold **`flatbread.config.js`**: -If everything goes well, you'll see a pretty `graphql` endpoint echoed out to your console by Flatbread. If you open that link in your browser, Apollo Studio will open for you to explore the schema Flatbread generated. Apollo Studio has some nice auto-prediction and gives you helpers in the schema explorer for building your queries. +```bash +pnpm exec flatbread init +``` -You can query that same endpoint in your app in any way you'd like. Flatbread doesn't care what framework you use. +Point **`content`** entries at **your** `posts/` and **`authors/`** folders, reuse the relational ideas above, and add **`codegen`** in config when you want **`generated/graphql.ts`**. Browse [`packages`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages) for plugins and resolver helpers. -> NOTE: detecting changes to your content while Flatbread is running is [not yet supported](https://github.com/FlatbreadLabs/flatbread/issues/65). You'll have to restart the process to get updated content. +More detail on the bundled example (scripts, codegen watch, troubleshooting): **`examples/nextjs/README.md`**. -## Query arguments +## Query arguments (GraphQL read interface) -The following arguments are listed in their order of operation. +When **GraphQL** is your read interface, list fields use the following arguments in order of application. ### `filter` @@ -249,9 +365,9 @@ Skips the specified number of entries. Accepts an integer. Limits the number of returned entries to the specified amount. Accepts an integer. -## Query within your app ❓❓ +## Query from your app -[Check out the example integrations](https://github.com/FlatbreadLabs/flatbread/tree/main/examples) of using Flatbread with frameworks like SvelteKit and Next.js. +Follow [Quickstart (posts, authors, and tags)](#quickstart-posts-authors-and-tags) for the relational model, **codegen**, and typed results. For framework wiring and scripts, use **[examples/nextjs](https://github.com/FlatbreadLabs/flatbread/tree/main/examples/nextjs)** or [other examples](https://github.com/FlatbreadLabs/flatbread/tree/main/examples) (for example SvelteKit). ## Field overrides @@ -311,4 +427,4 @@ Accepts a function which takes in field names and transforms them for the GraphQ # ☀️ Contributing -See [CONTRIBUTING.md](./CONTRIBUTING.md) for the release workflow (bumping versions and publishing). +See [CONTRIBUTING.md](https://github.com/FlatbreadLabs/flatbread/blob/main/CONTRIBUTING.md) for the release workflow (bumping versions and publishing). diff --git a/packages/proof/README.md b/packages/proof/README.md index 24333e7f..69efbc60 100644 --- a/packages/proof/README.md +++ b/packages/proof/README.md @@ -4,7 +4,7 @@ Proof is Flatbread's DAG task runner for Cursor agents. It decomposes a task int The package ships as `@flatbread/proof` and exposes: -- `proof`: run a DAG or initialize its canvas. +- `proof`: run a DAG, initialize its canvas, or generate `proof setup` artifacts. - `proof-supervisor`: run Proof in self-hosting mode so edits to `packages/proof/src/**` can be picked up between ranks. - Library exports for tooling that wants to author, validate, or inspect DAGs programmatically. @@ -57,6 +57,39 @@ pnpm exec proof \ --canvas-path /tmp/example-dag.canvas.tsx ``` +## `proof setup` + +`proof setup` prepares repo-owned Proof guidance without launching agents by default: + +```bash +pnpm exec proof setup +``` + +Default output lives under `/.flatbread/proof/setup/`: + +```text +owned-guidelines.bundle.md +owned-guidelines.manifest.json +setup-dag.json +setup-summary.md +``` + +Behavior: + +- Reuses the existing owned-guidelines bundle + manifest when the owned Proof guidance sources are still fresh. +- Regenerates them when Proof guidance sources changed. +- Computes setup gaps and writes a runnable setup DAG + summary, but does not launch agents unless you opt in. +- When the generated DAG does launch agents, it inserts an explicit post-edit refresh step before review; that step rebuilds `@flatbread/proof` and then reruns `proof setup` so runtime edits do not regenerate artifacts through an old packaged CLI. +- Bakes in an explicit maintenance contract: if Proof-related work changes rules, docs, skills, prompts, or runtime behavior, update the owned source files, rebuild `@flatbread/proof`, and rerun `proof setup` so the derived bundle does not become stale. + +Opt in to handing the generated DAG to the existing runner: + +```bash +pnpm exec proof setup --run-agents --canvas proof-setup +``` + +The authoritative owned guidance sources currently include `AGENTS.md`, `.cursor/rules/proof-usage-guardrails.mdc`, `packages/proof/README.md`, `.cursor/skills/proof/SKILL.md`, and the legacy `.cursor/skills/dag-task-runner/SKILL.md` compatibility handoff so reruns notice stale redirects too. + ## DAG Shape Every DAG has a `title` and a `tasks` array. Each task needs: @@ -100,6 +133,46 @@ Optional task kinds add control gates: - `kind: "oracle"` runs a shell command and records pass/fail evidence. - `kind: "pause"` waits for a checkpoint sentinel so a human can inspect or approve before downstream work continues. +## `DAG.loops` + +Bounded convergence loops can live in the DAG itself instead of only on the CLI. This keeps the run reproducible: contributors do not need to remember a matching `--converge-on ... --max-iterations ...` flag pair. + +```json +{ + "title": "implement then review until clean", + "loops": [ + { + "convergeOn": "review", + "maxIterations": 3, + "reexecute": { "kind": "tasks", "tasks": ["implement"] } + } + ], + "tasks": [ + { + "id": "implement", + "depends_on": [], + "complexity": "MED", + "subtask_prompt": "Implement the feature." + }, + { + "id": "review", + "depends_on": ["implement"], + "complexity": "HIGH", + "subtask_prompt": "Review the implementation. Use `## Blockers` and `## High-severity findings` when needed." + } + ] +} +``` + +Notes: + +- Omit `id` to get the default `loop-` id. +- Omit `reexecute` to re-run the full ancestor cone, which matches the legacy CLI behavior. +- `reexecute: { "kind": "tasks", "tasks": [...] }` must stay inside the convergence task's ancestor cone and be dependency-closed for every non-`convergeOn` task it names; invalid subsets fail fast during DAG parsing with the missing ancestor ids. +- Parsed explicit rerun lists always include `convergeOn` itself, even if the authored JSON omits it. +- `DAG.loops` and `--converge-on` are mutually exclusive. If the DAG already declares loops, remove the CLI flag instead of relying on precedence. +- Multiple loops are allowed only when their re-execution sets are disjoint, so one loop cannot invalidate another loop's converged result later in the run. + ## Artifact Output By default, every **full DAG run** writes per-task markdown transcripts to a timestamped directory (not `--init-only`, which exits before artifact setup, and not `--dry-check-cmds`, which never enters the runner): @@ -138,7 +211,7 @@ The canonical Cursor skill entrypoint lives at: .cursor/skills/proof/SKILL.md ``` -Use that skill when a request asks to decompose work, run subagents in parallel, or execute a task as a dependency graph. The legacy `.cursor/skills/dag-task-runner/SKILL.md` entry remains as a compatibility handoff and points to Proof. +Use that skill when a request asks to decompose work, run subagents in parallel, or execute a task as a dependency graph. The legacy `.cursor/skills/dag-task-runner/SKILL.md` entry remains as a compatibility handoff, points to Proof, and is also tracked by `proof setup` as repo-owned guidance. ## Self-Hosting Mode @@ -166,10 +239,15 @@ pnpm -F @flatbread/proof build ```bash pnpm -F @flatbread/proof typecheck pnpm -F @flatbread/proof build +pnpm -F @flatbread/proof test +pnpm -F @flatbread/proof build && pnpm exec proof setup +pnpm test pnpm -F @flatbread/proof models:list pnpm exec proof --dry-check-cmds --dag .cursor/skills/proof/examples/example_dag.json ``` +`pnpm -F @flatbread/proof test` is the focused bounded-loop suite. Root `pnpm test` also reaches that AVA file through `ava.config.js`. + ## Library API Proof also exposes helpers for tooling: diff --git a/packages/proof/bin/proof.js b/packages/proof/bin/proof.js index b350074a..2ae4df02 100755 --- a/packages/proof/bin/proof.js +++ b/packages/proof/bin/proof.js @@ -2,6 +2,8 @@ import { resolve } from 'path'; import { existsSync } from 'fs'; +const entryFile = process.argv[2] === 'setup' ? 'setup.js' : 'run_dag.js'; + if (process.env.FLATBREAD_CI) { const cliPath = resolve( process.cwd(), @@ -9,14 +11,14 @@ if (process.env.FLATBREAD_CI) { '@flatbread', 'proof', 'dist', - 'run_dag.js' + entryFile ); if (existsSync(cliPath)) { - import('../dist/run_dag.js'); + import(`../dist/${entryFile}`); } else { console.log('@flatbread/proof CLI is not available'); } } else { - import('../dist/run_dag.js'); + import(`../dist/${entryFile}`); } diff --git a/packages/proof/package.json b/packages/proof/package.json index dbb3a271..e30a8e9c 100644 --- a/packages/proof/package.json +++ b/packages/proof/package.json @@ -6,6 +6,8 @@ "scripts": { "build": "tsup", "dev": "tsup --watch src", + "test": "pnpm --dir ../.. exec ava \"packages/proof/src/**/*.test.ts\"", + "test:watch": "pnpm --dir ../.. exec ava --watch \"packages/proof/src/**/*.test.ts\"", "typecheck": "tsc -p tsconfig.json --noEmit", "models:list": "tsx src/list_models.ts", "cursor:fetch-cloud-agent": "node scripts/fetch-cloud-agent-conversation.mjs" diff --git a/packages/proof/src/__tests__/loops.test.ts b/packages/proof/src/__tests__/loops.test.ts new file mode 100644 index 00000000..b3078cb4 --- /dev/null +++ b/packages/proof/src/__tests__/loops.test.ts @@ -0,0 +1,470 @@ +import test from 'ava'; +import { + parseDAG, + resolveConvergenceLoops, + type DAG, + type DAGConvergenceLoop, + type RawTask, +} from '../index.js'; +import { resolveLoopReexecuteIds } from '../converge_loop.js'; + +const baseTasks: RawTask[] = [ + { + id: 'research', + depends_on: [], + complexity: 'LOW', + subtask_prompt: 'research', + kind: 'task', + }, + { + id: 'design', + depends_on: ['research'], + complexity: 'MED', + subtask_prompt: 'design', + kind: 'task', + }, + { + id: 'implement', + depends_on: ['design'], + complexity: 'MED', + subtask_prompt: 'implement', + kind: 'task', + }, + { + id: 'review', + depends_on: ['implement'], + complexity: 'HIGH', + subtask_prompt: 'review', + kind: 'task', + }, +]; + +function dagWith(loops: unknown): unknown { + return { + title: 'loop-tests', + tasks: baseTasks.map((t) => ({ + id: t.id, + depends_on: t.depends_on, + complexity: t.complexity, + subtask_prompt: t.subtask_prompt, + })), + loops, + }; +} + +test('parseDAG accepts a minimal loops entry with defaults', (t) => { + const dag = parseDAG(dagWith([{ convergeOn: 'review', maxIterations: 2 }])); + t.truthy(dag.loops); + t.is(dag.loops!.length, 1); + t.is(dag.loops![0].convergeOn, 'review'); + t.is(dag.loops![0].maxIterations, 2); +}); + +test('resolveConvergenceLoops fills defaults', (t) => { + const resolved = resolveConvergenceLoops([ + { convergeOn: 'review', maxIterations: 2 }, + ]); + t.is(resolved[0].id, 'loop-review'); + t.deepEqual(resolved[0].reexecute, { kind: 'ancestors' }); +}); + +test('parseDAG rejects convergeOn referencing unknown task id', (t) => { + t.throws( + () => parseDAG(dagWith([{ convergeOn: 'nope', maxIterations: 2 }])), + { message: /not a task id/ } + ); +}); + +test('parseDAG rejects non-positive maxIterations', (t) => { + t.throws( + () => parseDAG(dagWith([{ convergeOn: 'review', maxIterations: 0 }])), + { message: /maxIterations must be a positive integer/ } + ); + t.throws( + () => parseDAG(dagWith([{ convergeOn: 'review', maxIterations: -1 }])), + { message: /maxIterations must be a positive integer/ } + ); + t.throws( + () => parseDAG(dagWith([{ convergeOn: 'review', maxIterations: 1.5 }])), + { message: /maxIterations must be a positive integer/ } + ); +}); + +test('parseDAG rejects two loops with the same convergeOn', (t) => { + t.throws( + () => + parseDAG( + dagWith([ + { convergeOn: 'review', maxIterations: 2 }, + { convergeOn: 'review', maxIterations: 3 }, + ]) + ), + { message: /duplicate convergeOn/ } + ); +}); + +test('parseDAG rejects two loops with the same explicit id', (t) => { + t.throws( + () => + parseDAG( + dagWith([ + { id: 'shared', convergeOn: 'review', maxIterations: 2 }, + { id: 'shared', convergeOn: 'design', maxIterations: 2 }, + ]) + ), + { message: /resolved loop id.*shared.*collides/ } + ); +}); + +test("parseDAG rejects loops whose resolved ids collide (explicit id matches another loop's default)", (t) => { + // Loop 0 has no explicit id: resolves to 'loop-review' via default. + // Loop 1 explicitly sets id: 'loop-review', convergeOn a different task. + // Before the fix these two loops silently produced duplicate resolved ids; + // after the fix parseDAG must throw. + t.throws( + () => + parseDAG( + dagWith([ + { convergeOn: 'review', maxIterations: 2 }, + { id: 'loop-review', convergeOn: 'implement', maxIterations: 2 }, + ]) + ), + { message: /resolved loop id.*loop-review.*collides/ } + ); +}); + +test('parseDAG rejects explicit ids that collide with defaulted loop ids', (t) => { + t.throws( + () => + parseDAG( + dagWith([ + { convergeOn: 'review', maxIterations: 2 }, + { id: 'loop-review', convergeOn: 'design', maxIterations: 2 }, + ]) + ), + { message: /duplicate loop id/ } + ); +}); + +test('parseDAG accepts explicit reexecute.tasks when the subset is dependency-closed', (t) => { + const dag = parseDAG( + dagWith([ + { + convergeOn: 'review', + maxIterations: 2, + reexecute: { + kind: 'tasks', + tasks: ['research', 'design', 'implement'], + }, + }, + ]) + ); + const reexec = dag.loops![0].reexecute!; + t.is(reexec.kind, 'tasks'); + if (reexec.kind === 'tasks') { + // convergeOn is injected so the loop body always re-runs the + // convergence task itself after upstream re-execution. + t.deepEqual([...reexec.tasks].sort(), [ + 'design', + 'implement', + 'research', + 'review', + ]); + } +}); + +test('parseDAG deduplicates convergeOn from reexecute.tasks when caller includes it explicitly', (t) => { + const dag = parseDAG( + dagWith([ + { + convergeOn: 'review', + maxIterations: 2, + reexecute: { + kind: 'tasks', + tasks: ['research', 'design', 'implement', 'review'], + }, // review = convergeOn + }, + ]) + ); + const reexec = dag.loops![0].reexecute!; + t.is(reexec.kind, 'tasks'); + if (reexec.kind === 'tasks') { + // 'review' must appear exactly once despite being both the convergeOn and explicit in the list + t.deepEqual([...reexec.tasks].sort(), [ + 'design', + 'implement', + 'research', + 'review', + ]); + } +}); + +test('parseDAG accepts a pause task as convergeOn (behavior: allowed, convergence semantics may be vacuous)', (t) => { + const raw = { + title: 'pause-convergeOn', + tasks: [ + { id: 'gate', depends_on: [], subtask_prompt: 'wait', kind: 'pause' }, + ], + loops: [{ convergeOn: 'gate', maxIterations: 1 }], + }; + const dag = parseDAG(raw); + t.is(dag.loops![0].convergeOn, 'gate'); +}); + +test('parseDAG rejects reexecute.tasks outside the ancestor cone', (t) => { + // 'review' depends on 'implement' which depends on 'design' which depends + // on 'research'. A task `unrelated` that is not in that cone should be + // rejected (we synthesize one off the side of the DAG). + const raw = { + title: 'cone-test', + tasks: [ + ...baseTasks.map((t) => ({ + id: t.id, + depends_on: t.depends_on, + complexity: t.complexity, + subtask_prompt: t.subtask_prompt, + })), + { + id: 'sibling', + depends_on: [], + complexity: 'LOW', + subtask_prompt: 'sibling', + }, + ], + loops: [ + { + convergeOn: 'review', + maxIterations: 2, + reexecute: { kind: 'tasks', tasks: ['sibling'] }, + }, + ], + }; + t.throws(() => parseDAG(raw), { + message: /not the convergeOn task and is not a transitive ancestor/, + }); +}); + +test('parseDAG rejects reexecute.tasks containing unknown task ids', (t) => { + t.throws( + () => + parseDAG( + dagWith([ + { + convergeOn: 'review', + maxIterations: 2, + reexecute: { kind: 'tasks', tasks: ['ghost'] }, + }, + ]) + ), + { message: /unknown task id/ } + ); +}); + +test('parseDAG rejects non-closed reexecute.tasks subsets', (t) => { + t.throws( + () => + parseDAG( + dagWith([ + { + convergeOn: 'review', + maxIterations: 2, + reexecute: { kind: 'tasks', tasks: ['implement'] }, + }, + ]) + ), + { message: /must be dependency-closed/ } + ); +}); + +test('parseDAG rejects unknown reexecute.kind', (t) => { + t.throws( + () => + parseDAG( + dagWith([ + { + convergeOn: 'review', + maxIterations: 2, + reexecute: { kind: 'all', tasks: [] }, + }, + ]) + ), + { message: /reexecute\.kind must be one of/ } + ); +}); + +test('parseDAG with no loops still works', (t) => { + const dag = parseDAG({ + title: 'no-loops', + tasks: [ + { + id: 'only', + depends_on: [], + complexity: 'LOW', + subtask_prompt: 'x', + }, + ], + }); + t.is(dag.loops, undefined); +}); + +test('resolveLoopReexecuteIds with ancestors returns the full cone', (t) => { + const dag = parseDAG( + dagWith([{ convergeOn: 'review', maxIterations: 2 }]) + ) as DAG; + const resolved = resolveConvergenceLoops(dag.loops!); + const ids = resolveLoopReexecuteIds(resolved[0], dag); + t.deepEqual([...ids].sort(), ['design', 'implement', 'research', 'review']); +}); + +test('resolveLoopReexecuteIds with explicit tasks honors the allow-list', (t) => { + const dag = parseDAG( + dagWith([ + { + convergeOn: 'review', + maxIterations: 2, + reexecute: { + kind: 'tasks', + tasks: ['research', 'design', 'implement'], + }, + }, + ]) + ) as DAG; + const resolved = resolveConvergenceLoops(dag.loops!); + const ids = resolveLoopReexecuteIds(resolved[0], dag); + // Only the explicit allow-list + convergence task itself. + t.deepEqual([...ids].sort(), ['design', 'implement', 'research', 'review']); +}); + +test('resolveConvergenceLoops preserves user-provided id when set', (t) => { + const dag = parseDAG( + dagWith([{ id: 'review-loop', convergeOn: 'review', maxIterations: 3 }]) + ); + const resolved = resolveConvergenceLoops(dag.loops!); + t.is(resolved[0].id, 'review-loop'); + t.is(resolved[0].maxIterations, 3); +}); + +test('parseDAG accepts multiple loops when their re-execution sets are disjoint', (t) => { + const tasks = [ + { + id: 'research', + depends_on: [], + complexity: 'LOW', + subtask_prompt: 'r', + }, + { + id: 'docs', + depends_on: [], + complexity: 'MED', + subtask_prompt: 'd', + }, + { + id: 'docs-review', + depends_on: ['docs'], + complexity: 'HIGH', + subtask_prompt: 'dr', + }, + { + id: 'impl', + depends_on: [], + complexity: 'MED', + subtask_prompt: 'i', + }, + { + id: 'impl-review', + depends_on: ['impl'], + complexity: 'HIGH', + subtask_prompt: 'ir', + }, + ]; + const dag = parseDAG({ + title: 'multi-loop', + tasks, + loops: [ + { convergeOn: 'docs-review', maxIterations: 2 }, + { convergeOn: 'impl-review', maxIterations: 2 }, + ], + }); + t.is(dag.loops!.length, 2); + const resolved = resolveConvergenceLoops(dag.loops!); + t.deepEqual( + resolved.map((l) => l.id), + ['loop-docs-review', 'loop-impl-review'] + ); +}); + +test('parseDAG rejects loops with overlapping re-execution sets', (t) => { + t.throws( + () => + parseDAG({ + title: 'overlap', + tasks: [ + { + id: 'shared', + depends_on: [], + complexity: 'LOW', + subtask_prompt: 'shared', + }, + { + id: 'docs', + depends_on: ['shared'], + complexity: 'MED', + subtask_prompt: 'docs', + }, + { + id: 'docs-review', + depends_on: ['docs'], + complexity: 'HIGH', + subtask_prompt: 'docs review', + }, + { + id: 'impl', + depends_on: ['shared'], + complexity: 'MED', + subtask_prompt: 'impl', + }, + { + id: 'impl-review', + depends_on: ['impl'], + complexity: 'HIGH', + subtask_prompt: 'impl review', + }, + ], + loops: [ + { convergeOn: 'docs-review', maxIterations: 2 }, + { convergeOn: 'impl-review', maxIterations: 2 }, + ], + }), + { message: /must have disjoint re-execution sets/ } + ); +}); + +test('parseDAG rejects non-array loops', (t) => { + t.throws(() => parseDAG(dagWith({ convergeOn: 'review' })), { + message: /must be an array/, + }); +}); + +test('DAGConvergenceLoop type round-trips through resolveConvergenceLoops', (t) => { + const declared: DAGConvergenceLoop[] = [ + { + id: 'r', + convergeOn: 'review', + maxIterations: 5, + reexecute: { + kind: 'tasks', + tasks: ['research', 'design', 'implement', 'review'], + }, + }, + ]; + const resolved = resolveConvergenceLoops(declared); + t.deepEqual(resolved[0], { + id: 'r', + convergeOn: 'review', + maxIterations: 5, + reexecute: { + kind: 'tasks', + tasks: ['research', 'design', 'implement', 'review'], + }, + }); +}); diff --git a/packages/proof/src/cli_dispatch.ts b/packages/proof/src/cli_dispatch.ts new file mode 100644 index 00000000..d81ed04e --- /dev/null +++ b/packages/proof/src/cli_dispatch.ts @@ -0,0 +1,11 @@ +export type ProofCliEntrypoint = 'run_dag' | 'setup'; + +/** + * Keep the historical `proof --dag ...` contract intact by only peeling off + * an explicit `setup` subcommand in argv slot 0 (`process.argv[2]` in the bin). + */ +export function selectProofCliEntrypoint( + argv: readonly string[] +): ProofCliEntrypoint { + return argv[0] === 'setup' ? 'setup' : 'run_dag'; +} diff --git a/packages/proof/src/converge_loop.ts b/packages/proof/src/converge_loop.ts index 4fd75166..046e229b 100644 --- a/packages/proof/src/converge_loop.ts +++ b/packages/proof/src/converge_loop.ts @@ -22,7 +22,11 @@ * the same topological order as the original run. */ -import type { DAG } from './dag.js'; +import { + transitiveAncestorIds, + type DAG, + type ResolvedConvergenceLoop, +} from './dag.js'; export interface ConvergenceFindings { hasIssues: boolean; @@ -116,21 +120,34 @@ const PLACEHOLDER_WORDS = new Set([ ]); export function transitiveAncestors(taskId: string, dag: DAG): Set { - const byId = new Map(dag.tasks.map((t) => [t.id, t])); - const visited = new Set(); - const start = byId.get(taskId); - if (!start) return visited; + return transitiveAncestorIds(taskId, dag.tasks); +} - const stack: string[] = [...start.depends_on]; - while (stack.length > 0) { - const id = stack.pop()!; - if (visited.has(id)) continue; - visited.add(id); - const t = byId.get(id); - if (!t) continue; - for (const dep of t.depends_on) stack.push(dep); +/** + * Resolves a single loop's `reexecute` selector into the concrete set of + * task ids the runner re-executes per iteration. Always includes the + * convergence task itself so the loop body can re-run it after upstream + * re-execution. Pure function — does not mutate the DAG or the loop. + * + * - `{ kind: 'ancestors' }` → `transitiveAncestors(convergeOn) ∪ {convergeOn}`, + * matching the legacy `--converge-on` behavior. + * - `{ kind: 'tasks'; tasks: [...] }` → the validated allow-list (already + * guaranteed at parse time to lie inside the convergence ancestor cone). + * The convergence task id is added defensively even though `parseDAG` + * already injects it during validation. + */ +export function resolveLoopReexecuteIds( + loop: ResolvedConvergenceLoop, + dag: DAG +): Set { + if (loop.reexecute.kind === 'ancestors') { + const ids = transitiveAncestors(loop.convergeOn, dag); + ids.add(loop.convergeOn); + return ids; } - return visited; + const ids = new Set(loop.reexecute.tasks); + ids.add(loop.convergeOn); + return ids; } /** diff --git a/packages/proof/src/dag.ts b/packages/proof/src/dag.ts index 77dc5472..ad12f049 100644 --- a/packages/proof/src/dag.ts +++ b/packages/proof/src/dag.ts @@ -98,6 +98,19 @@ export interface DAG { framing?: string; budget?: DAGBudget; tasks: RawTask[]; + /** + * Optional first-class bounded convergence loops. Each entry generalizes + * the legacy CLI `--converge-on`/`--max-iterations` pair into a DAG-native + * declaration so the same JSON file is reproducibly runnable without + * remembering the right flags. + * + * Loops execute sequentially in declaration order after the main rank loop + * completes. `--converge-on` may not be combined with `loops`; the runner + * errors at startup if both are set. Loop re-execution sets must also be + * disjoint so one loop cannot silently invalidate another loop's already + * converged outcome. + */ + loops?: DAGConvergenceLoop[]; } export interface DAGBudget { @@ -105,6 +118,53 @@ export interface DAGBudget { maxTokensTotal?: number; } +/** + * Selector for which tasks a convergence loop re-executes per iteration. + * + * - `{ kind: 'ancestors' }` — default, mirrors the legacy CLI behavior: + * re-runs every transitive ancestor of `convergeOn` plus `convergeOn` + * itself. + * - `{ kind: 'tasks'; tasks: [...] }` — explicit allow-list. Every id must + * be a known task, must lie inside the convergence ancestor cone + * (`transitiveAncestors(convergeOn) ∪ {convergeOn}`); ids outside that + * cone are rejected at parse time because re-running them would break + * topological ordering of the filtered re-execution ranks. The explicit + * list must also be dependency-closed for every non-`convergeOn` task it + * names so the runner never mixes a fresh task with stale upstream inputs. + */ +export type LoopReexecute = + | { kind: 'ancestors' } + | { kind: 'tasks'; tasks: string[] }; +/** + * First-class bounded convergence loop. Generalizes the singleton CLI + * `--converge-on`/`--max-iterations` pair into a DAG-native config so a + * single run can stack multiple convergence tasks (e.g. one for the + * implementation reviewer, one for the docs reviewer) and so DAG-emitting + * tooling can declare loop intent reproducibly. + */ +export interface DAGConvergenceLoop { + /** Stable id for canvas/log display. Defaults to `loop-${convergeOn}` when omitted. */ + id?: string; + /** Task whose `## Blockers` / `## High-severity findings` drive the loop. */ + convergeOn: string; + /** Iteration ceiling. Iteration 0 is the original main-rank run. */ + maxIterations: number; + /** What to re-execute per iteration. Defaults to `{ kind: 'ancestors' }`. */ + reexecute?: LoopReexecute; +} + +/** Loop config with all defaults filled in — what the runner actually consumes. */ +export interface ResolvedConvergenceLoop { + id: string; + convergeOn: string; + maxIterations: number; + reexecute: LoopReexecute; +} + +const LOOP_REEXECUTE_KINDS = new Set([ + 'ancestors', + 'tasks', +]); const COMPLEXITY_VALUES = new Set(['HIGH', 'MED', 'LOW']); export const COMPLEXITY_KEYS: readonly Complexity[] = [ 'HIGH', @@ -194,10 +254,271 @@ export function parseDAG(raw: unknown): DAG { obj.framing === undefined ? undefined : validateFraming(obj.framing); const budget = obj.budget === undefined ? undefined : validateBudget(obj.budget); + const loops = + obj.loops === undefined ? undefined : validateLoops(obj.loops, tasks); + + return { title: obj.title, models, framing, budget, tasks, loops }; +} + +/** + * Returns the closed set of transitive ancestor ids for `taskId` in the + * given task list (the union of `depends_on` reached by repeated + * traversal). Canonical transitive-ancestor traversal shared with + * `converge_loop.ts`. Defined here (takes `RawTask[]` not a full `DAG` + * object) so `parseDAG` can validate `loops.reexecute.tasks` without a + * circular module import; `converge_loop.ts:transitiveAncestors` delegates + * to this function. + */ +export function transitiveAncestorIds( + taskId: string, + tasks: RawTask[] +): Set { + const byId = new Map(tasks.map((t) => [t.id, t])); + const visited = new Set(); + const start = byId.get(taskId); + if (!start) return visited; + const stack: string[] = [...start.depends_on]; + while (stack.length > 0) { + const id = stack.pop()!; + if (visited.has(id)) continue; + visited.add(id); + const t = byId.get(id); + if (!t) continue; + for (const dep of t.depends_on) stack.push(dep); + } + return visited; +} + +function validateLoops(raw: unknown, tasks: RawTask[]): DAGConvergenceLoop[] { + if (!Array.isArray(raw)) { + throw new Error('DAG.loops must be an array of loop config objects.'); + } + const taskIds = new Set(tasks.map((t) => t.id)); + const loops: DAGConvergenceLoop[] = []; + const seenConvergeOn = new Set(); + const seenResolvedIds = new Set(); + for (let i = 0; i < raw.length; i++) { + const loop = validateLoop(raw[i], i, taskIds, tasks); + if (seenConvergeOn.has(loop.convergeOn)) { + throw new Error( + `DAG.loops[${i}]: duplicate convergeOn "${loop.convergeOn}" — each loop must drive a distinct task.` + ); + } + seenConvergeOn.add(loop.convergeOn); + const resolvedId = loop.id ?? `loop-${loop.convergeOn}`; + if (seenResolvedIds.has(resolvedId)) { + throw new Error( + `DAG.loops[${i}]: duplicate loop id; resolved loop id "${resolvedId}" collides with a previous loop's id. ` + + `Set an explicit \`id\` on one of the colliding loops to disambiguate.` + ); + } + seenResolvedIds.add(resolvedId); + loops.push(loop); + } + validateLoopInteractions(loops, tasks); + return loops; +} + +function validateLoop( + raw: unknown, + index: number, + taskIds: Set, + tasks: RawTask[] +): DAGConvergenceLoop { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error(`DAG.loops[${index}] must be a JSON object.`); + } + const obj = raw as Record; + const convergeOn = obj.convergeOn; + if (typeof convergeOn !== 'string' || convergeOn.trim() === '') { + throw new Error( + `DAG.loops[${index}].convergeOn must be a non-empty string.` + ); + } + if (!taskIds.has(convergeOn)) { + throw new Error( + `DAG.loops[${index}].convergeOn "${convergeOn}" is not a task id in this DAG.` + ); + } + const maxIterations = obj.maxIterations; + if ( + typeof maxIterations !== 'number' || + !Number.isSafeInteger(maxIterations) || + maxIterations <= 0 + ) { + throw new Error( + `DAG.loops[${index}].maxIterations must be a positive integer.` + ); + } + let id: string | undefined; + if (obj.id !== undefined) { + if (typeof obj.id !== 'string' || obj.id.trim() === '') { + throw new Error( + `DAG.loops[${index}].id must be a non-empty string when set.` + ); + } + id = obj.id; + } + let reexecute: LoopReexecute | undefined; + if (obj.reexecute !== undefined) { + reexecute = validateReexecute( + obj.reexecute, + index, + taskIds, + convergeOn, + tasks + ); + } + const loop: DAGConvergenceLoop = { convergeOn, maxIterations }; + if (id !== undefined) loop.id = id; + if (reexecute !== undefined) loop.reexecute = reexecute; + return loop; +} - return { title: obj.title, models, framing, budget, tasks }; +function validateReexecute( + raw: unknown, + loopIndex: number, + taskIds: Set, + convergeOn: string, + tasks: RawTask[] +): LoopReexecute { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error( + `DAG.loops[${loopIndex}].reexecute must be a JSON object when set.` + ); + } + const obj = raw as Record; + const kind = obj.kind; + if ( + typeof kind !== 'string' || + !LOOP_REEXECUTE_KINDS.has(kind as LoopReexecute['kind']) + ) { + throw new Error( + `DAG.loops[${loopIndex}].reexecute.kind must be one of: ${[ + ...LOOP_REEXECUTE_KINDS, + ].join(' | ')}.` + ); + } + if (kind === 'ancestors') { + return { kind: 'ancestors' }; + } + const list = obj.tasks; + if ( + !Array.isArray(list) || + list.length === 0 || + list.some((t) => typeof t !== 'string' || t.trim() === '') + ) { + throw new Error( + `DAG.loops[${loopIndex}].reexecute.tasks must be a non-empty array of task id strings.` + ); + } + const requested = list as string[]; + for (const id of requested) { + if (!taskIds.has(id)) { + throw new Error( + `DAG.loops[${loopIndex}].reexecute.tasks contains unknown task id "${id}".` + ); + } + } + // The re-execution set must be a subset of the convergence ancestor cone + // (ancestors of convergeOn ∪ convergeOn itself). Re-running a task that + // is not a transitive dependency of the convergence task would break the + // filtered topological order: the runner re-executes ranks in the + // convergence task's downward causal chain, so an unrelated task would + // either run out of order or not at all. + const cone = transitiveAncestorIds(convergeOn, tasks); + cone.add(convergeOn); + for (const id of requested) { + if (!cone.has(id)) { + throw new Error( + `DAG.loops[${loopIndex}].reexecute.tasks contains "${id}" which is not the convergeOn task and is not a transitive ancestor of "${convergeOn}".` + ); + } + } + const selected = new Set(requested); + for (const id of requested) { + if (id === convergeOn) continue; + const missingAncestors = [...transitiveAncestorIds(id, tasks)].filter( + (ancestorId) => cone.has(ancestorId) && !selected.has(ancestorId) + ); + if (missingAncestors.length > 0) { + throw new Error( + `DAG.loops[${loopIndex}].reexecute.tasks must be dependency-closed. Task "${id}" also requires its ancestor(s): ${missingAncestors.join( + ', ' + )}. Add them or remove "${id}".` + ); + } + } + // Always include the convergence task itself so the loop body can re-run + // it after upstream re-execution. De-dupe while preserving caller order. + const seen = new Set(); + const tasksOut: string[] = []; + for (const id of [...requested, convergeOn]) { + if (seen.has(id)) continue; + seen.add(id); + tasksOut.push(id); + } + return { kind: 'tasks', tasks: tasksOut }; } +/** + * Fills in defaults (`id`, `reexecute`) for each declared loop so the runner + * can consume a single canonical shape regardless of which fields the DAG + * author left implicit. Pure function — does not access the DAG task list. + * Defaults align with the legacy `--converge-on` behavior: re-execute the + * full ancestor cone and stop when the convergence task's `## Blockers` / + * `## High-severity findings` are both empty. + */ +export function resolveConvergenceLoops( + loops: readonly DAGConvergenceLoop[] +): ResolvedConvergenceLoop[] { + return loops.map((loop) => ({ + id: loop.id ?? `loop-${loop.convergeOn}`, + convergeOn: loop.convergeOn, + maxIterations: loop.maxIterations, + reexecute: loop.reexecute ?? { kind: 'ancestors' }, + })); +} + +function validateLoopInteractions( + loops: readonly DAGConvergenceLoop[], + tasks: RawTask[] +): void { + const reExecSets = loops.map((loop) => ({ + id: loop.id ?? `loop-${loop.convergeOn}`, + taskIds: computeLoopReexecuteIds(loop, tasks), + })); + for (let i = 0; i < reExecSets.length; i++) { + for (let j = i + 1; j < reExecSets.length; j++) { + const overlap = [...reExecSets[i].taskIds].filter((id) => + reExecSets[j].taskIds.has(id) + ); + if (overlap.length === 0) continue; + throw new Error( + `DAG.loops must have disjoint re-execution sets. "${ + reExecSets[i].id + }" and "${reExecSets[j].id}" both re-run: ${overlap.join( + ', ' + )}. Split the DAG so each loop owns a separate task cone, or collapse the work into one loop.` + ); + } + } +} + +function computeLoopReexecuteIds( + loop: DAGConvergenceLoop, + tasks: RawTask[] +): Set { + const ids = new Set(); + if (loop.reexecute?.kind === 'tasks') { + for (const id of loop.reexecute.tasks) ids.add(id); + ids.add(loop.convergeOn); + return ids; + } + for (const id of transitiveAncestorIds(loop.convergeOn, tasks)) ids.add(id); + ids.add(loop.convergeOn); + return ids; +} function validateFraming(raw: unknown): string { if (typeof raw !== 'string') { throw new Error('DAG.framing must be a string when set.'); diff --git a/packages/proof/src/index.ts b/packages/proof/src/index.ts index 3458d7f5..81c46646 100644 --- a/packages/proof/src/index.ts +++ b/packages/proof/src/index.ts @@ -18,6 +18,7 @@ export { isPauseTask, normalizeModelSelection, parseDAG, + resolveConvergenceLoops, resolveModelSelectionFromCatalog, validateModelSelection, validateModelMap, @@ -26,6 +27,8 @@ export type { Complexity, DAG, DAGBudget, + DAGConvergenceLoop, + LoopReexecute, ModelCatalogItem, ModelMap, ModelMapOverride, @@ -33,6 +36,7 @@ export type { ModelSelection, ModelSpec, RawTask, + ResolvedConvergenceLoop, ResolvedModelMap, TaskKind, } from './dag.js'; @@ -43,8 +47,10 @@ export type { RunState, TaskState, TaskStatus } from './canvas_writer.js'; export { buildConvergenceContext, extractConvergenceFindings, + resolveLoopReexecuteIds, transitiveAncestors, } from './converge_loop.js'; +export { transitiveAncestorIds } from './dag.js'; export type { ConvergenceFindings } from './converge_loop.js'; export { diff --git a/packages/proof/src/run_dag.ts b/packages/proof/src/run_dag.ts index 0565dae9..30b8622c 100644 --- a/packages/proof/src/run_dag.ts +++ b/packages/proof/src/run_dag.ts @@ -95,6 +95,7 @@ import { createModelSelectionResolver, formatModelSelection, normalizeModelSelection, + resolveConvergenceLoops, validateModelMap, } from './dag.js'; import type { @@ -126,6 +127,7 @@ import { import { buildConvergenceContext, extractConvergenceFindings, + resolveLoopReexecuteIds, transitiveAncestors, } from './converge_loop.js'; import { @@ -150,6 +152,9 @@ const RUNNER_SOURCE_DIR = SCRIPTS_DIR.endsWith('/src') ? SCRIPTS_DIR : resolve(SCRIPTS_DIR, '..', 'src'); +const SUPPRESS_AUTO_RUN_KEY = Symbol.for( + '@flatbread/proof.run_dag.suppress_auto_main' +); interface CliArgs { dag: string; /** Empty in `--dry-check-cmds` mode (no canvas is written). */ @@ -527,8 +532,10 @@ async function fetchCursorModelCatalog(): Promise< } } -async function main(): Promise { - const args = parseArgs(process.argv.slice(2)); +export async function runDagCli( + argv: string[] = process.argv.slice(2) +): Promise { + const args = parseArgs(argv); // --dry-check-cmds: short-circuit before any SDK / canvas / API-key work. // The contract here is "given a DAG file and a workspace, would these @@ -594,6 +601,27 @@ async function main(): Promise { `--converge-on "${args.convergeOn}" is not a task id in DAG "${dag.title}"` ); } + // The CLI flag and the DAG-native `loops` config both produce convergence + // loops; combining them silently would force a precedence rule and make + // reproducible runs depend on whether someone remembered to pass the + // flag. Reject the combination outright. + if (args.convergeOn && dag.loops && dag.loops.length > 0) { + throw new Error( + `--converge-on "${args.convergeOn}" cannot be combined with DAG.loops (DAG "${dag.title}" already declares ${dag.loops.length} loop(s)). Remove the CLI flag to use the DAG's loops, or delete DAG.loops for an ad-hoc CLI-driven run.` + ); + } + // Synthesize a single-element loop list from the CLI flag so the runner + // treats both entry points uniformly. `--max-iterations` (CLI) feeds the + // synthesized loop's `maxIterations`; `dag.budget.maxIterations` + // continues to apply on top per-loop via the existing budget check. + const resolvedLoops = + dag.loops !== undefined && dag.loops.length > 0 + ? resolveConvergenceLoops(dag.loops) + : args.convergeOn !== undefined + ? resolveConvergenceLoops([ + { convergeOn: args.convergeOn, maxIterations: args.maxIterations }, + ]) + : []; const fullOutputAbsoluteDir: string | undefined = (() => { if (args.noArtifacts || args.initOnly || args.dryCheckCmds) @@ -911,11 +939,20 @@ async function main(): Promise { } await maybeRestartAfterRunnerChange('main ranks before convergence'); - if (args.convergeOn) { + // Loops execute sequentially in declaration order. `parseDAG()` already + // rejected overlapping re-execution sets, so one loop cannot silently + // invalidate another loop's converged task state by re-running shared + // ancestors afterwards. A loop that hits BUDGET-EXCEEDED still lets later + // loops run — each loop's terminal state is independent and surfaces + // through the per-task status tally, the same way the legacy single-loop + // CLI worked. + for (const loop of resolvedLoops) { + const reExecIds = resolveLoopReexecuteIds(loop, dag); await runConvergenceLoop({ - convergeOn: args.convergeOn, - maxIterations: args.maxIterations, - dag, + loopId: loop.id, + convergeOn: loop.convergeOn, + maxIterations: loop.maxIterations, + reExecIds, ranks, stateById, dispatchTask, @@ -926,9 +963,9 @@ async function main(): Promise { afterIteration: async (iteration: number) => { writer.schedule(structuredCloneState(state)); await writer.flush(); - await persistState(`completed convergence iteration ${iteration}`); + await persistState(`completed ${loop.id} iteration ${iteration}`); await maybeRestartAfterRunnerChange( - `convergence iteration ${iteration}` + `${loop.id} iteration ${iteration}` ); }, }); @@ -1091,7 +1128,6 @@ async function main(): Promise { } } } - async function runTask( task: RawTask, stateById: Map, @@ -1553,9 +1589,17 @@ async function writeRunIndexMarkdown( } interface RunConvergenceLoopOptions { + /** Stable id used in canvas/log messages. Either the user-provided loop id or `loop-${convergeOn}`. */ + loopId: string; convergeOn: string; maxIterations: number; - dag: DAG; + /** + * Precomputed re-execution id set. Always contains `convergeOn` itself so + * the loop body can re-run it after upstream re-execution completes. The + * caller computes this from the loop's `reexecute` selector via + * `resolveLoopReexecuteIds`. + */ + reExecIds: Set; ranks: RawTask[][]; stateById: Map; dispatchTask: ( @@ -1601,9 +1645,10 @@ async function runConvergenceLoop( opts: RunConvergenceLoopOptions ): Promise { const { + loopId, convergeOn, maxIterations, - dag, + reExecIds, ranks, stateById, dispatchTask, @@ -1617,20 +1662,19 @@ async function runConvergenceLoop( if (!convergeTs) { // Defensive — main() already validates this, but the loop must not crash. console.error( - `[proof] --converge-on "${convergeOn}" not found in state; skipping convergence loop` + `[proof] ${loopId}: convergence task "${convergeOn}" not found in state; skipping` ); return; } - const ancestorIds = transitiveAncestors(convergeOn, dag); - const reExecIds = new Set([...ancestorIds, convergeOn]); // Filter the original ranks to just the re-executed tasks. Drop empty // ranks. Order is preserved → topological correctness is preserved. const reExecRanks: RawTask[][] = ranks .map((rank) => rank.filter((t) => reExecIds.has(t.id))) .filter((rank) => rank.length > 0); - for (let iter = 1; iter <= maxIterations; iter++) { + const startingIteration = (convergeTs.iteration ?? 0) + 1; + for (let iter = startingIteration; iter <= maxIterations; iter++) { // Prefer the findings-dir JSON sidecar when one was written for the most // recent run of the convergence task; the sidecar is captured at task // completion, so it survives the streaming buffer churn that can occasionally @@ -1648,7 +1692,7 @@ async function runConvergenceLoop( ); if (!findings.hasIssues) { console.log( - `[proof] converge-on ${convergeOn}: clean — no Blockers / High-severity findings after ${ + `[proof] ${loopId} (converge-on ${convergeOn}): clean — no Blockers / High-severity findings after ${ iter - 1 } re-iteration(s)` ); @@ -1677,13 +1721,13 @@ async function runConvergenceLoop( convergeTs.errorMessage = `Convergence iteration ${iter} would exceed budget.maxIterations=${budget.maxIterations}`; writer.schedule(structuredCloneState(state)); console.log( - `[proof] converge-on ${convergeOn}: BUDGET-EXCEEDED — iteration ${iter} would exceed budget.maxIterations=${budget.maxIterations}` + `[proof] ${loopId} (converge-on ${convergeOn}): BUDGET-EXCEEDED — iteration ${iter} would exceed budget.maxIterations=${budget.maxIterations}` ); return; } console.log( - `[proof] converge iteration ${iter}/${maxIterations}: ${findings.blockerLines.length} blocker(s), ${findings.highSeverityLines.length} high-severity finding(s) — re-running ${reExecIds.size} task(s)` + `[proof] ${loopId} iteration ${iter}/${maxIterations}: ${findings.blockerLines.length} blocker(s), ${findings.highSeverityLines.length} high-severity finding(s) — re-running ${reExecIds.size} task(s)` ); const convergenceContext = buildConvergenceContext( @@ -1722,13 +1766,13 @@ async function runConvergenceLoop( await afterIteration?.(iter); } - // CLI --max-iterations exhausted. Re-parse the convergence task's latest + // CLI/DAG maxIterations exhausted. Re-parse the convergence task's latest // output (preferring the post-run sidecar over live `resultText`, same as // the loop body) and, if blockers / high-severity findings are still // present, surface this as a budget-style terminal state on the // convergence task. The existing main-run tally then bumps `runOutcome` - // to `'FAILED'` and the process exits with `EXIT_BUDGET_EXCEEDED` (4) — - // matching how `--budget` enforcement signals overflow today. + // to `'BUDGET_EXCEEDED'` and the process exits with + // `EXIT_BUDGET_EXCEEDED` (4), matching token-budget enforcement. const finalSidecarText = findingsDir !== undefined ? await readFindingsSidecarAsText( @@ -1762,11 +1806,11 @@ async function runConvergenceLoop( } } console.log( - `[proof] converge-on ${convergeOn}: BUDGET-EXCEEDED — exhausted --max-iterations=${maxIterations} with ${finalFindings.blockerLines.length} blocker(s), ${finalFindings.highSeverityLines.length} high-severity finding(s)` + `[proof] ${loopId} (converge-on ${convergeOn}): BUDGET-EXCEEDED — exhausted maxIterations=${maxIterations} with ${finalFindings.blockerLines.length} blocker(s), ${finalFindings.highSeverityLines.length} high-severity finding(s)` ); } else { console.log( - `[proof] converge-on ${convergeOn}: clean after ${maxIterations} re-iteration(s)` + `[proof] ${loopId} (converge-on ${convergeOn}): clean after ${maxIterations} re-iteration(s)` ); } } @@ -1983,9 +2027,11 @@ function structuredCloneState(state: RunState): RunState { return JSON.parse(JSON.stringify(state)) as RunState; } -main().catch((err) => { - console.error( - `[proof] fatal: ${err instanceof Error ? err.stack ?? err.message : err}` - ); - process.exit(1); -}); +if (!(globalThis as Record)[SUPPRESS_AUTO_RUN_KEY]) { + runDagCli().catch((err) => { + console.error( + `[proof] fatal: ${err instanceof Error ? err.stack ?? err.message : err}` + ); + process.exit(1); + }); +} diff --git a/packages/proof/src/setup.test.ts b/packages/proof/src/setup.test.ts new file mode 100644 index 00000000..ee325f6f --- /dev/null +++ b/packages/proof/src/setup.test.ts @@ -0,0 +1,388 @@ +import test from 'ava'; +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdtemp } from 'node:fs/promises'; +import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { selectProofCliEntrypoint } from './cli_dispatch.js'; +import { + buildProofSetupRefreshCommand, + computeSetupGaps, + createSetupDag, + discoverExpectedGuidelines, + prepareOwnedGuidelinesBundle, + proofSetupUpdateDirective, +} from './setup_helpers.js'; + +const execFileAsync = promisify(execFile); +const suppressSetupAutoMainKey = Symbol.for( + '@flatbread/proof.setup.suppress_auto_main' +); +(globalThis as Record)[suppressSetupAutoMainKey] = true; +const { runSetupCli } = await import('./setup.js'); +delete (globalThis as Record)[suppressSetupAutoMainKey]; + +async function makeTempRepo(): Promise { + const root = await mkdtemp(join(tmpdir(), 'proof-setup-test-')); + await mkdir(join(root, '.cursor', 'rules'), { recursive: true }); + await mkdir(join(root, '.cursor', 'skills', 'proof'), { recursive: true }); + await mkdir(join(root, '.cursor', 'skills', 'dag-task-runner'), { + recursive: true, + }); + await mkdir(join(root, 'packages', 'proof'), { recursive: true }); + await writeFile(join(root, 'AGENTS.md'), '# Agents\n', 'utf8'); + await writeFile( + join(root, '.cursor', 'rules', 'proof-usage-guardrails.mdc'), + '# Guardrails\n', + 'utf8' + ); + await writeFile( + join(root, '.cursor', 'skills', 'proof', 'SKILL.md'), + '# Skill\n', + 'utf8' + ); + await writeFile( + join(root, '.cursor', 'skills', 'dag-task-runner', 'SKILL.md'), + '# Legacy skill\n', + 'utf8' + ); + await writeFile( + join(root, 'packages', 'proof', 'README.md'), + '# Proof\n', + 'utf8' + ); + return root; +} + +test('selectProofCliEntrypoint only routes explicit setup subcommand', (t) => { + t.is(selectProofCliEntrypoint(['setup']), 'setup'); + t.is(selectProofCliEntrypoint(['--dag', '/tmp/example.json']), 'run_dag'); + t.is(selectProofCliEntrypoint([]), 'run_dag'); +}); + +test('discoverExpectedGuidelines includes the legacy compatibility skill handoff', async (t) => { + const tempRoot = await makeTempRepo(); + t.teardown(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + + const expected = await discoverExpectedGuidelines(tempRoot); + + t.true( + expected.some( + (guideline) => + guideline.relativePath === '.cursor/skills/dag-task-runner/SKILL.md' + ) + ); +}); + +test('prepareOwnedGuidelinesBundle reuses a fresh bundle and regenerates after source changes', async (t) => { + const tempRoot = await makeTempRepo(); + t.teardown(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + const outDir = join(tempRoot, '.flatbread', 'proof', 'setup'); + const bundlePath = join(outDir, 'owned-guidelines.bundle.md'); + const manifestPath = join(outDir, 'owned-guidelines.manifest.json'); + + const firstExpected = await discoverExpectedGuidelines(tempRoot); + const first = await prepareOwnedGuidelinesBundle({ + cwd: tempRoot, + bundlePath, + manifestPath, + expectedGuidelines: firstExpected, + }); + t.is(first.status, 'regenerated'); + t.false(first.freshness.isFresh); + t.true(first.bundleText.includes(proofSetupUpdateDirective())); + t.regex(first.manifest.bundleSha256, /^[a-f0-9]{64}$/); + + const secondExpected = await discoverExpectedGuidelines(tempRoot); + const second = await prepareOwnedGuidelinesBundle({ + cwd: tempRoot, + bundlePath, + manifestPath, + expectedGuidelines: secondExpected, + }); + t.is(second.status, 'reused'); + t.true(second.freshness.isFresh); + t.is(second.bundleText, await readFile(bundlePath, 'utf8')); + + await writeFile(bundlePath, '# corrupted bundle\n', 'utf8'); + await writeFile( + manifestPath, + JSON.stringify(second.manifest, null, 2) + '\n', + 'utf8' + ); + const thirdExpected = await discoverExpectedGuidelines(tempRoot); + const third = await prepareOwnedGuidelinesBundle({ + cwd: tempRoot, + bundlePath, + manifestPath, + expectedGuidelines: thirdExpected, + }); + t.is(third.status, 'regenerated'); + t.false(third.freshness.isFresh); + t.true( + third.freshness.reasons.some((reason) => + reason.includes('bundle content does not match current source files') + ) + ); + + await writeFile( + join(tempRoot, 'AGENTS.md'), + '# Agents\n\nUpdated.\n', + 'utf8' + ); + const fourthExpected = await discoverExpectedGuidelines(tempRoot); + const fourth = await prepareOwnedGuidelinesBundle({ + cwd: tempRoot, + bundlePath, + manifestPath, + expectedGuidelines: fourthExpected, + }); + t.is(fourth.status, 'regenerated'); + t.false(fourth.freshness.isFresh); + t.true( + fourth.freshness.reasons.some((reason) => + reason.includes('AGENTS.md changed since last bundle') + ) + ); + + await writeFile( + manifestPath, + JSON.stringify( + { + ...fourth.manifest, + sources: fourth.manifest.sources.map((source) => + source.path === 'AGENTS.md' + ? { ...source, title: 'Tampered title' } + : source + ), + }, + null, + 2 + ) + '\n', + 'utf8' + ); + const fifthExpected = await discoverExpectedGuidelines(tempRoot); + const fifth = await prepareOwnedGuidelinesBundle({ + cwd: tempRoot, + bundlePath, + manifestPath, + expectedGuidelines: fifthExpected, + }); + t.is(fifth.status, 'regenerated'); + t.false(fifth.freshness.isFresh); + t.true( + fifth.freshness.reasons.some((reason) => + reason.includes('manifest metadata changed for AGENTS.md (title)') + ) + ); +}); + +test('computeSetupGaps reports missing expected guidelines', async (t) => { + const tempRoot = await makeTempRepo(); + t.teardown(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + await rm(join(tempRoot, '.cursor', 'skills', 'proof', 'SKILL.md')); + + const expected = await discoverExpectedGuidelines(tempRoot); + const outDir = join(tempRoot, '.flatbread', 'proof', 'setup'); + const bundlePath = join(outDir, 'owned-guidelines.bundle.md'); + const manifestPath = join(outDir, 'owned-guidelines.manifest.json'); + const bundle = await prepareOwnedGuidelinesBundle({ + cwd: tempRoot, + bundlePath, + manifestPath, + expectedGuidelines: expected, + }); + const gaps = computeSetupGaps(expected, bundle.manifest); + + t.true(gaps.hasGaps); + t.deepEqual(gaps.missingExpectedGuidelines, [ + '.cursor/skills/proof/SKILL.md', + ]); + t.deepEqual(gaps.missingFromOwnedBundle, []); + t.deepEqual(gaps.staleOwnedBundleEntries, []); +}); + +test('createSetupDag adds a post-edit refresh step and review loop when gaps exist', (t) => { + const dag = createSetupDag({ + cwd: '/tmp/workspace', + bundlePath: + '/tmp/workspace/.flatbread/proof/setup/owned-guidelines.bundle.md', + manifestPath: + '/tmp/workspace/.flatbread/proof/setup/owned-guidelines.manifest.json', + summaryPath: '/tmp/workspace/.flatbread/proof/setup/setup-summary.md', + gaps: { + missingExpectedGuidelines: ['.cursor/skills/proof/SKILL.md'], + missingFromOwnedBundle: [], + staleOwnedBundleEntries: [], + unexpectedOwnedBundleEntries: [], + hasGaps: true, + }, + }); + + t.truthy(dag.framing?.includes(proofSetupUpdateDirective())); + t.deepEqual( + dag.tasks.map((task) => task.id), + [ + 'inspect-proof-setup-context', + 'close-proof-setup-gaps', + 'refresh-proof-setup-artifacts', + 'review-proof-setup', + ] + ); + const refreshTask = dag.tasks.find( + (task) => task.id === 'refresh-proof-setup-artifacts' + ); + t.truthy(refreshTask); + t.is(refreshTask?.kind, 'oracle'); + t.is( + refreshTask?.command, + buildProofSetupRefreshCommand('/tmp/workspace', '.flatbread/proof/setup') + ); + t.deepEqual(refreshTask?.depends_on, ['close-proof-setup-gaps']); + const reviewTask = dag.tasks.find((task) => task.id === 'review-proof-setup'); + t.deepEqual(reviewTask?.depends_on, ['refresh-proof-setup-artifacts']); + t.is(dag.loops?.[0].convergeOn, 'review-proof-setup'); + t.deepEqual(dag.loops?.[0].reexecute, { + kind: 'tasks', + tasks: ['close-proof-setup-gaps', 'refresh-proof-setup-artifacts'], + }); +}); + +test('buildProofSetupRefreshCommand rebuilds before packaged setup rerun', (t) => { + const command = buildProofSetupRefreshCommand( + '/tmp/workspace', + '.flatbread/proof/setup' + ); + + t.true(command.startsWith('pnpm -F @flatbread/proof build &&')); + t.true(command.includes('pnpm exec proof setup')); + t.true(command.includes("--cwd '/tmp/workspace'")); + t.true(command.includes("--out-dir '.flatbread/proof/setup'")); +}); + +test('runSetupCli writes setup artifacts without launching agents by default', async (t) => { + const tempRoot = await makeTempRepo(); + t.teardown(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + const outDir = join(tempRoot, '.flatbread', 'proof', 'setup'); + + await runSetupCli(['--cwd', tempRoot, '--out-dir', outDir]); + const summary = await readFile(join(outDir, 'setup-summary.md'), 'utf8'); + const bundle = await readFile( + join(outDir, 'owned-guidelines.bundle.md'), + 'utf8' + ); + + t.true(summary.includes('Generated setup DAG')); + t.true(bundle.includes('.cursor/skills/dag-task-runner/SKILL.md')); +}); + +test('runSetupCli resolves explicit out-dir relative to --cwd', async (t) => { + const tempRoot = await makeTempRepo(); + t.teardown(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + + await runSetupCli(['--cwd', tempRoot, '--out-dir', 'custom-setup']); + + t.true(existsSync(join(tempRoot, 'custom-setup', 'setup-summary.md'))); + t.true( + existsSync(join(tempRoot, 'custom-setup', 'owned-guidelines.bundle.md')) + ); +}); + +test('runSetupCli can hand the generated DAG to the existing runner', async (t) => { + const tempRoot = await makeTempRepo(); + t.teardown(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + const outDir = join(tempRoot, '.flatbread', 'proof', 'setup'); + const canvasPath = join(tempRoot, 'proof-setup.canvas.tsx'); + let handedOffArgs: string[] | undefined; + + await runSetupCli( + [ + '--cwd', + tempRoot, + '--out-dir', + outDir, + '--run-agents', + '--init-only', + '--canvas-path', + canvasPath, + ], + { + runDagCli: async (argv) => { + handedOffArgs = argv; + await writeFile(canvasPath, '// mock canvas\n', 'utf8'); + }, + } + ); + + t.true(existsSync(join(outDir, 'setup-dag.json'))); + t.true(existsSync(canvasPath)); + t.deepEqual(handedOffArgs, [ + '--init-only', + '--canvas-path', + canvasPath, + '--cwd', + tempRoot, + '--dag', + join(outDir, 'setup-dag.json'), + ]); +}); + +test('bin/proof.js dispatches to setup and run_dag dist entries', async (t) => { + const tempRoot = await mkdtemp(join(tmpdir(), 'proof-bin-test-')); + t.teardown(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + const pkgRoot = join(tempRoot, 'proof-pkg'); + const binDir = join(pkgRoot, 'bin'); + const distDir = join(pkgRoot, 'dist'); + await mkdir(binDir, { recursive: true }); + await mkdir(distDir, { recursive: true }); + await writeFile( + join(pkgRoot, 'package.json'), + '{\n "type": "module"\n}\n', + 'utf8' + ); + await copyFile( + fileURLToPath(new URL('../bin/proof.js', import.meta.url)), + join(binDir, 'proof.js') + ); + await writeFile( + join(distDir, 'setup.js'), + 'console.log("setup-entry");\n', + 'utf8' + ); + await writeFile( + join(distDir, 'run_dag.js'), + 'console.log("run-dag-entry");\n', + 'utf8' + ); + + const setupResult = await execFileAsync('node', [ + join(binDir, 'proof.js'), + 'setup', + ]); + const dagResult = await execFileAsync('node', [ + join(binDir, 'proof.js'), + '--dag', + '/tmp/example.json', + ]); + + t.true(setupResult.stdout.includes('setup-entry')); + t.true(dagResult.stdout.includes('run-dag-entry')); +}); diff --git a/packages/proof/src/setup.ts b/packages/proof/src/setup.ts new file mode 100644 index 00000000..ab6345d0 --- /dev/null +++ b/packages/proof/src/setup.ts @@ -0,0 +1,197 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import process from 'node:process'; + +import { + computeSetupGaps, + createSetupDag, + discoverExpectedGuidelines, + prepareOwnedGuidelinesBundle, + renderSetupSummary, +} from './setup_helpers.js'; + +const SUPPRESS_AUTO_SETUP_KEY = Symbol.for( + '@flatbread/proof.setup.suppress_auto_main' +); + +interface SetupCliArgs { + cwd: string; + outDir: string; + runAgents: boolean; + runnerArgs: string[]; + help: boolean; +} + +export interface SetupCliDependencies { + runDagCli?: (argv: string[]) => Promise; +} + +function parseSetupArgs(argv: string[]): SetupCliArgs { + const normalized = argv[0] === 'setup' ? argv.slice(1) : argv.slice(); + const cwd = process.cwd(); + let resolvedCwd = cwd; + let explicitOutDirRaw: string | undefined; + let runAgents = false; + let help = false; + const runnerArgs: string[] = []; + + for (let i = 0; i < normalized.length; i++) { + const arg = normalized[i]; + const next = normalized[i + 1]; + if (arg === '--help' || arg === '-h') { + help = true; + continue; + } + if (arg === '--run-agents') { + runAgents = true; + continue; + } + if (arg === '--cwd') { + if (!next || next.startsWith('--')) { + throw new Error('--cwd is required'); + } + resolvedCwd = resolve(next); + i++; + continue; + } + if (arg === '--out-dir') { + if (!next || next.startsWith('--')) { + throw new Error('--out-dir is required'); + } + explicitOutDirRaw = next; + i++; + continue; + } + + runnerArgs.push(arg); + if (arg.startsWith('--') && next && !next.startsWith('--')) { + runnerArgs.push(next); + i++; + } + } + + if (runnerArgs.includes('--dag')) { + throw new Error( + '`proof setup` manages its generated DAG path. Use `--out-dir` instead of passing `--dag`.' + ); + } + + return { + cwd: resolvedCwd, + outDir: + explicitOutDirRaw !== undefined + ? resolve(resolvedCwd, explicitOutDirRaw) + : resolve(resolvedCwd, '.flatbread', 'proof', 'setup'), + runAgents, + runnerArgs, + help, + }; +} + +function setupUsage(): string { + return [ + 'Usage: proof setup [--cwd ] [--out-dir ] [--run-agents] [runner args...]', + '', + 'Default mode refreshes/reuses the owned-guidelines bundle + manifest, computes setup gaps, and writes a setup DAG/summary without launching agents.', + '', + 'When `--run-agents` is set, the generated DAG is handed to the existing Proof runner.', + ].join('\n'); +} + +function hasRunnerCanvasArg(args: readonly string[]): boolean { + return args.includes('--canvas') || args.includes('--canvas-path'); +} + +function hasRunnerCwdArg(args: readonly string[]): boolean { + return args.includes('--cwd'); +} + +export async function runSetupCli( + argv: string[] = process.argv.slice(2), + deps: SetupCliDependencies = {} +): Promise { + const parsed = parseSetupArgs(argv); + if (parsed.help) { + console.log(setupUsage()); + return; + } + + await mkdir(parsed.outDir, { recursive: true }); + + const bundlePath = resolve(parsed.outDir, 'owned-guidelines.bundle.md'); + const manifestPath = resolve(parsed.outDir, 'owned-guidelines.manifest.json'); + const dagPath = resolve(parsed.outDir, 'setup-dag.json'); + const summaryPath = resolve(parsed.outDir, 'setup-summary.md'); + + const expectedGuidelines = await discoverExpectedGuidelines(parsed.cwd); + const bundle = await prepareOwnedGuidelinesBundle({ + cwd: parsed.cwd, + bundlePath, + manifestPath, + expectedGuidelines, + }); + const gaps = computeSetupGaps(expectedGuidelines, bundle.manifest); + const dag = createSetupDag({ + cwd: parsed.cwd, + bundlePath, + manifestPath, + summaryPath, + gaps, + }); + const summary = renderSetupSummary({ + cwd: parsed.cwd, + bundle, + gaps, + dagPath, + }); + + await writeFile(dagPath, JSON.stringify(dag, null, 2) + '\n', 'utf8'); + await writeFile(summaryPath, summary, 'utf8'); + + console.log(`[proof setup] owned guidelines: ${bundle.status}`); + console.log(`[proof setup] bundle → ${bundlePath}`); + console.log(`[proof setup] manifest → ${manifestPath}`); + console.log(`[proof setup] setup DAG → ${dagPath}`); + console.log(`[proof setup] summary → ${summaryPath}`); + console.log(`[proof setup] gaps: ${gaps.hasGaps ? 'present' : 'none'}`); + + if (!parsed.runAgents) { + return; + } + + const runnerArgs = [...parsed.runnerArgs]; + if (!hasRunnerCwdArg(runnerArgs)) { + runnerArgs.push('--cwd', parsed.cwd); + } + if (!hasRunnerCanvasArg(runnerArgs)) { + runnerArgs.push('--canvas', 'proof-setup'); + } + runnerArgs.push('--dag', dagPath); + + if (deps.runDagCli !== undefined) { + await deps.runDagCli(runnerArgs); + return; + } + + const suppressAutoRunKey = Symbol.for( + '@flatbread/proof.run_dag.suppress_auto_main' + ); + (globalThis as Record)[suppressAutoRunKey] = true; + try { + const { runDagCli } = await import('./run_dag.js'); + await runDagCli(runnerArgs); + } finally { + delete (globalThis as Record)[suppressAutoRunKey]; + } +} + +if (!(globalThis as Record)[SUPPRESS_AUTO_SETUP_KEY]) { + runSetupCli().catch((err) => { + console.error( + `[proof setup] fatal: ${ + err instanceof Error ? err.stack ?? err.message : err + }` + ); + process.exit(1); + }); +} diff --git a/packages/proof/src/setup_helpers.ts b/packages/proof/src/setup_helpers.ts new file mode 100644 index 00000000..c0e32ca0 --- /dev/null +++ b/packages/proof/src/setup_helpers.ts @@ -0,0 +1,754 @@ +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { basename, dirname, relative, resolve } from 'node:path'; + +import type { DAG, RawTask } from './dag.js'; + +export type ExpectedGuidelineCategory = + | 'workspace-rule' + | 'workspace-contract' + | 'package-readme' + | 'skill'; + +export interface ExpectedGuideline { + id: string; + title: string; + category: ExpectedGuidelineCategory; + absolutePath: string; + relativePath: string; + exists: boolean; + content?: string; + sha256?: string; + sizeBytes?: number; + mtimeMs?: number; +} + +export interface OwnedGuidelineManifestSource { + id: string; + title: string; + category: ExpectedGuidelineCategory; + path: string; + sha256: string; + sizeBytes: number; + mtimeMs: number; +} + +export interface OwnedGuidelinesManifest { + version: 2; + generatedAt: string; + generator: 'proof setup'; + bundlePath: string; + bundleSha256: string; + missingExpectedGuidelines: string[]; + updateDirective: string; + sources: OwnedGuidelineManifestSource[]; +} + +export interface OwnedGuidelinesFreshness { + isFresh: boolean; + reasons: string[]; +} + +export interface OwnedGuidelinesBundleResult { + status: 'reused' | 'regenerated'; + bundlePath: string; + manifestPath: string; + bundleText: string; + manifest: OwnedGuidelinesManifest; + freshness: OwnedGuidelinesFreshness; +} + +export interface SetupGaps { + missingExpectedGuidelines: string[]; + missingFromOwnedBundle: string[]; + staleOwnedBundleEntries: string[]; + unexpectedOwnedBundleEntries: string[]; + hasGaps: boolean; +} + +const PROOF_SETUP_UPDATE_DIRECTIVE = + 'If Proof-related work changes rules, docs, skills, prompts, or runtime behavior, update the authoritative source files, run `pnpm -F @flatbread/proof build`, and rerun `pnpm exec proof setup` before concluding so the owned-guidelines bundle and manifest do not go stale.'; + +const EXPECTED_GUIDELINE_CANDIDATES: Array<{ + id: string; + title: string; + category: ExpectedGuidelineCategory; + relativePath: string; +}> = [ + { + id: 'proof-usage-guardrails', + title: 'Proof usage guardrails', + category: 'workspace-rule', + relativePath: '.cursor/rules/proof-usage-guardrails.mdc', + }, + { + id: 'workspace-agents', + title: 'Workspace agent contract', + category: 'workspace-contract', + relativePath: 'AGENTS.md', + }, + { + id: 'proof-readme', + title: '@flatbread/proof README', + category: 'package-readme', + relativePath: 'packages/proof/README.md', + }, + { + id: 'proof-skill', + title: 'Proof skill guide', + category: 'skill', + relativePath: '.cursor/skills/proof/SKILL.md', + }, + { + id: 'dag-task-runner-compat-skill', + title: 'Legacy dag-task-runner compatibility skill', + category: 'skill', + relativePath: '.cursor/skills/dag-task-runner/SKILL.md', + }, +]; + +function sha256(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +function normalizeRelativePath(cwd: string, absolutePath: string): string { + const rel = relative(cwd, absolutePath); + if (rel === '') return '.'; + return rel.split('\\').join('/'); +} + +function compareStringArrays( + a: readonly string[], + b: readonly string[] +): boolean { + if (a.length !== b.length) return false; + return a.every((value, idx) => value === b[idx]); +} + +function sortStrings(values: readonly string[]): string[] { + return [...values].sort((a, b) => a.localeCompare(b)); +} + +function fileCodeFenceLanguage(path: string): string { + if (path.endsWith('.md') || path.endsWith('.mdc')) return 'md'; + if (path.endsWith('.json')) return 'json'; + return 'text'; +} + +function setupGapLines(gaps: SetupGaps): string[] { + const lines: string[] = []; + if (gaps.missingExpectedGuidelines.length > 0) { + lines.push( + `Missing expected guidelines: ${gaps.missingExpectedGuidelines.join( + ', ' + )}` + ); + } + if (gaps.missingFromOwnedBundle.length > 0) { + lines.push( + `Expected guidelines missing from owned bundle: ${gaps.missingFromOwnedBundle.join( + ', ' + )}` + ); + } + if (gaps.staleOwnedBundleEntries.length > 0) { + lines.push( + `Owned bundle entries that are stale versus source files: ${gaps.staleOwnedBundleEntries.join( + ', ' + )}` + ); + } + if (gaps.unexpectedOwnedBundleEntries.length > 0) { + lines.push( + `Owned bundle contains unexpected entries: ${gaps.unexpectedOwnedBundleEntries.join( + ', ' + )}` + ); + } + if (lines.length === 0) { + lines.push('No Proof setup gaps were detected.'); + } + return lines; +} + +function renderGapChecklist(gaps: SetupGaps): string { + return setupGapLines(gaps) + .map((line) => `- ${line}`) + .join('\n'); +} + +function buildTask( + id: string, + depends_on: string[], + complexity: RawTask['complexity'], + subtask_prompt: string +): RawTask { + return { id, depends_on, complexity, subtask_prompt, kind: 'task' }; +} + +function buildOracleTask( + id: string, + depends_on: string[], + subtask_prompt: string, + command: string +): RawTask { + return { + id, + depends_on, + kind: 'oracle', + complexity: 'LOW', + subtask_prompt, + command, + }; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function isExistingGuideline( + guideline: ExpectedGuideline +): guideline is ExpectedGuideline & { + exists: true; + content: string; + sha256: string; + sizeBytes: number; + mtimeMs: number; +} { + return ( + guideline.exists && + typeof guideline.content === 'string' && + typeof guideline.sha256 === 'string' && + typeof guideline.sizeBytes === 'number' && + typeof guideline.mtimeMs === 'number' + ); +} + +export function proofSetupUpdateDirective(): string { + return PROOF_SETUP_UPDATE_DIRECTIVE; +} + +export function buildProofSetupRefreshCommand( + cwd: string, + outDir: string +): string { + return `pnpm -F @flatbread/proof build && pnpm exec proof setup --cwd ${shellQuote( + cwd + )} --out-dir ${shellQuote(outDir)}`; +} + +export async function discoverExpectedGuidelines( + cwd: string +): Promise { + const guidelines: ExpectedGuideline[] = []; + for (const candidate of EXPECTED_GUIDELINE_CANDIDATES) { + const absolutePath = resolve(cwd, candidate.relativePath); + if (!existsSync(absolutePath)) { + guidelines.push({ + ...candidate, + absolutePath, + exists: false, + }); + continue; + } + const [content, meta] = await Promise.all([ + readFile(absolutePath, 'utf8'), + stat(absolutePath), + ]); + guidelines.push({ + ...candidate, + absolutePath, + exists: true, + content, + sha256: sha256(content), + sizeBytes: meta.size, + mtimeMs: meta.mtimeMs, + }); + } + return guidelines; +} + +export function buildOwnedGuidelinesBundle( + guidelines: readonly ExpectedGuideline[], + bundleRelativePath: string +): { bundleText: string; missingExpectedGuidelines: string[] } { + const existing = guidelines.filter(isExistingGuideline); + const missingExpectedGuidelines = sortStrings( + guidelines + .filter((guideline) => !guideline.exists) + .map((guideline) => guideline.relativePath) + ); + const lines: string[] = [ + '# Owned Proof Guidelines Bundle', + '', + 'This file is derived by `pnpm exec proof setup`. Do not hand-edit it; edit the source files listed below instead.', + '', + '## Maintenance Contract', + '', + `- ${PROOF_SETUP_UPDATE_DIRECTIVE}`, + `- This derived bundle lives at \`${bundleRelativePath}\` and is only trustworthy when its manifest matches the current source files.`, + '- Treat the source files as authoritative when reconciling conflicts between this bundle and the repo.', + '', + '## Included Sources', + '', + ]; + + if (existing.length === 0) { + lines.push('_No owned Proof guideline sources were found._', ''); + } else { + for (const guideline of existing) { + lines.push( + `- \`${guideline.relativePath}\` (${guideline.category}, sha256=${guideline.sha256})` + ); + } + lines.push(''); + } + + lines.push('## Missing Expected Sources', ''); + if (missingExpectedGuidelines.length === 0) { + lines.push('_None._', ''); + } else { + for (const missing of missingExpectedGuidelines) { + lines.push(`- \`${missing}\``); + } + lines.push(''); + } + + for (const guideline of existing) { + lines.push(`## Source: \`${guideline.relativePath}\``, ''); + lines.push(`Category: ${guideline.category}`, ''); + lines.push( + `\`\`\`${fileCodeFenceLanguage(guideline.relativePath)}`, + guideline.content ?? '', + '```', + '' + ); + } + + return { + bundleText: lines.join('\n').trimEnd() + '\n', + missingExpectedGuidelines, + }; +} + +export function buildOwnedGuidelinesManifest( + guidelines: readonly ExpectedGuideline[], + bundleRelativePath: string, + bundleText: string +): OwnedGuidelinesManifest { + const existing = guidelines.filter(isExistingGuideline); + return { + version: 2, + generatedAt: new Date().toISOString(), + generator: 'proof setup', + bundlePath: bundleRelativePath, + bundleSha256: sha256(bundleText), + missingExpectedGuidelines: sortStrings( + guidelines + .filter((guideline) => !guideline.exists) + .map((guideline) => guideline.relativePath) + ), + updateDirective: PROOF_SETUP_UPDATE_DIRECTIVE, + sources: existing.map((guideline) => ({ + id: guideline.id, + title: guideline.title, + category: guideline.category, + path: guideline.relativePath, + sha256: guideline.sha256, + sizeBytes: guideline.sizeBytes, + mtimeMs: guideline.mtimeMs, + })), + }; +} + +export function computeOwnedGuidelinesFreshness( + cwd: string, + guidelines: readonly ExpectedGuideline[], + manifest: OwnedGuidelinesManifest | null, + bundlePath: string, + bundleText: string | null +): OwnedGuidelinesFreshness { + if (bundleText === null) { + return { + isFresh: false, + reasons: ['owned guidelines bundle is missing or unreadable'], + }; + } + if (manifest === null) { + return { + isFresh: false, + reasons: ['owned guidelines manifest is missing or unreadable'], + }; + } + + const reasons: string[] = []; + const bundleRelativePath = normalizeRelativePath(cwd, bundlePath); + const expectedBundle = buildOwnedGuidelinesBundle( + guidelines, + bundleRelativePath + ); + const expectedManifest = buildOwnedGuidelinesManifest( + guidelines, + bundleRelativePath, + expectedBundle.bundleText + ); + const expectedSourcesByPath = new Map( + expectedManifest.sources.map((source) => [source.path, source]) + ); + const manifestByPath = new Map( + manifest.sources.map((source) => [source.path, source]) + ); + + if (manifest.version !== 2) { + reasons.push( + `owned guidelines manifest version ${manifest.version} is stale` + ); + } + if (manifest.bundlePath !== bundleRelativePath) { + reasons.push('owned guidelines bundle path changed'); + } + if (manifest.bundleSha256 !== sha256(bundleText)) { + reasons.push('manifest bundle hash does not match bundle content'); + } + if (bundleText !== expectedBundle.bundleText) { + reasons.push( + 'owned guidelines bundle content does not match current source files' + ); + } + + for (const expectedSource of expectedManifest.sources) { + const manifestEntry = manifestByPath.get(expectedSource.path); + if (!manifestEntry) { + reasons.push(`manifest is missing ${expectedSource.path}`); + continue; + } + if (manifestEntry.id !== expectedSource.id) { + reasons.push(`manifest metadata changed for ${expectedSource.path} (id)`); + } + if (manifestEntry.title !== expectedSource.title) { + reasons.push( + `manifest metadata changed for ${expectedSource.path} (title)` + ); + } + if (manifestEntry.category !== expectedSource.category) { + reasons.push( + `manifest metadata changed for ${expectedSource.path} (category)` + ); + } + if (manifestEntry.sha256 !== expectedSource.sha256) { + reasons.push(`${expectedSource.path} changed since last bundle`); + } + if (manifestEntry.sizeBytes !== expectedSource.sizeBytes) { + reasons.push( + `manifest metadata changed for ${expectedSource.path} (sizeBytes)` + ); + } + if (manifestEntry.mtimeMs !== expectedSource.mtimeMs) { + reasons.push( + `manifest metadata changed for ${expectedSource.path} (mtimeMs)` + ); + } + } + + for (const source of manifest.sources) { + if (!expectedSourcesByPath.has(source.path)) { + reasons.push(`manifest contains unexpected source ${source.path}`); + } + } + + const expectedMissing = sortStrings( + expectedManifest.missingExpectedGuidelines + ); + const manifestMissing = sortStrings(manifest.missingExpectedGuidelines); + if (!compareStringArrays(expectedMissing, manifestMissing)) { + reasons.push('missing expected guideline set changed'); + } + + if (manifest.updateDirective !== PROOF_SETUP_UPDATE_DIRECTIVE) { + reasons.push('update directive changed'); + } + + return { + isFresh: reasons.length === 0, + reasons, + }; +} + +export function computeSetupGaps( + guidelines: readonly ExpectedGuideline[], + manifest: OwnedGuidelinesManifest +): SetupGaps { + const expectedExisting = new Map( + guidelines + .filter((guideline) => guideline.exists) + .map((guideline) => [guideline.relativePath, guideline]) + ); + const manifestByPath = new Map( + manifest.sources.map((source) => [source.path, source]) + ); + + const missingExpectedGuidelines = sortStrings( + guidelines + .filter((guideline) => !guideline.exists) + .map((guideline) => guideline.relativePath) + ); + const missingFromOwnedBundle = sortStrings( + [...expectedExisting.keys()].filter((path) => !manifestByPath.has(path)) + ); + const staleOwnedBundleEntries = sortStrings( + [...expectedExisting.entries()] + .filter(([path, guideline]) => { + const manifestEntry = manifestByPath.get(path); + return ( + manifestEntry !== undefined && + manifestEntry.sha256 !== guideline.sha256 + ); + }) + .map(([path]) => path) + ); + const unexpectedOwnedBundleEntries = sortStrings( + manifest.sources + .map((source) => source.path) + .filter((path) => !expectedExisting.has(path)) + ); + + return { + missingExpectedGuidelines, + missingFromOwnedBundle, + staleOwnedBundleEntries, + unexpectedOwnedBundleEntries, + hasGaps: + missingExpectedGuidelines.length > 0 || + missingFromOwnedBundle.length > 0 || + staleOwnedBundleEntries.length > 0 || + unexpectedOwnedBundleEntries.length > 0, + }; +} + +export async function prepareOwnedGuidelinesBundle(opts: { + cwd: string; + bundlePath: string; + manifestPath: string; + expectedGuidelines: readonly ExpectedGuideline[]; +}): Promise { + const { cwd, bundlePath, manifestPath, expectedGuidelines } = opts; + let existingBundleText: string | null = null; + if (existsSync(bundlePath)) { + try { + existingBundleText = await readFile(bundlePath, 'utf8'); + } catch { + existingBundleText = null; + } + } + let existingManifest: OwnedGuidelinesManifest | null = null; + if (existsSync(manifestPath)) { + try { + existingManifest = JSON.parse( + await readFile(manifestPath, 'utf8') + ) as OwnedGuidelinesManifest; + } catch { + existingManifest = null; + } + } + + const freshness = computeOwnedGuidelinesFreshness( + cwd, + expectedGuidelines, + existingManifest, + bundlePath, + existingBundleText + ); + + if ( + freshness.isFresh && + existingManifest !== null && + existingBundleText !== null + ) { + return { + status: 'reused', + bundlePath, + manifestPath, + bundleText: existingBundleText, + manifest: existingManifest, + freshness, + }; + } + + const bundleRelativePath = normalizeRelativePath(cwd, bundlePath); + const { bundleText } = buildOwnedGuidelinesBundle( + expectedGuidelines, + bundleRelativePath + ); + const manifest = buildOwnedGuidelinesManifest( + expectedGuidelines, + bundleRelativePath, + bundleText + ); + await mkdir(dirname(bundlePath), { recursive: true }); + await writeFile(bundlePath, bundleText, 'utf8'); + await writeFile( + manifestPath, + JSON.stringify(manifest, null, 2) + '\n', + 'utf8' + ); + + return { + status: 'regenerated', + bundlePath, + manifestPath, + bundleText, + manifest, + freshness, + }; +} + +export function createSetupDag(opts: { + cwd: string; + bundlePath: string; + manifestPath: string; + summaryPath: string; + gaps: SetupGaps; +}): DAG { + const { cwd, bundlePath, manifestPath, summaryPath, gaps } = opts; + const bundleRef = normalizeRelativePath(cwd, bundlePath); + const manifestRef = normalizeRelativePath(cwd, manifestPath); + const summaryRef = normalizeRelativePath(cwd, summaryPath); + const repoName = basename(resolve(cwd)); + const outDir = dirname(bundlePath); + const outDirRef = normalizeRelativePath(cwd, outDir); + + const inspectId = 'inspect-proof-setup-context'; + const closeId = 'close-proof-setup-gaps'; + const refreshId = 'refresh-proof-setup-artifacts'; + const reviewId = 'review-proof-setup'; + const tasks: RawTask[] = [ + buildTask( + inspectId, + [], + 'LOW', + [ + `Read \`${summaryRef}\`, \`${bundleRef}\`, and \`${manifestRef}\`.`, + 'Summarize the repo-owned Proof guidance, the current setup status, and the exact gaps that remain.', + `Treat the source files summarized in \`${bundleRef}\` as authoritative for edits; the bundle itself is derived context.`, + PROOF_SETUP_UPDATE_DIRECTIVE, + ].join('\n') + ), + ]; + + if (gaps.hasGaps) { + tasks.push( + buildTask( + closeId, + [inspectId], + 'MED', + [ + 'Close the remaining Proof setup gaps described below.', + renderGapChecklist(gaps), + `Use \`${summaryRef}\` and \`${bundleRef}\` as the starting context, but edit the authoritative source files in the repo.`, + PROOF_SETUP_UPDATE_DIRECTIVE, + ].join('\n\n') + ) + ); + tasks.push( + buildOracleTask( + refreshId, + [closeId], + [ + 'Refresh the derived Proof setup artifacts after any upstream edits.', + `Re-run \`proof setup\` so \`${bundleRef}\`, \`${manifestRef}\`, and \`${summaryRef}\` are regenerated from the current authoritative source files before review.`, + 'The refresh command rebuilds `@flatbread/proof` first so runtime/source edits are reflected in the packaged `proof setup` CLI.', + 'This refresh step is required whenever Proof-related work changes rules, docs, skills, prompts, or runtime behavior.', + ].join('\n\n'), + buildProofSetupRefreshCommand(cwd, outDirRef) + ) + ); + } + + tasks.push( + buildTask( + reviewId, + [gaps.hasGaps ? refreshId : inspectId], + 'HIGH', + [ + 'Review the current Proof setup state and any edits made by upstream tasks.', + 'If setup gaps remain, or if Proof-related work changed behavior without the refresh step re-running `proof setup` and re-ingesting the owned guidelines artifacts, report that under `## Blockers` or `## High-severity findings`.', + `Re-check \`${summaryRef}\`, \`${bundleRef}\`, and \`${manifestRef}\` before concluding.`, + ].join('\n\n') + ) + ); + + return { + title: `Proof setup for ${repoName}`, + framing: [ + 'You are working on Proof setup for this repository.', + `The derived owned-guidelines bundle is \`${bundleRef}\` and its manifest is \`${manifestRef}\`.`, + PROOF_SETUP_UPDATE_DIRECTIVE, + ].join('\n\n'), + tasks, + loops: gaps.hasGaps + ? [ + { + convergeOn: reviewId, + maxIterations: 2, + reexecute: { kind: 'tasks', tasks: [closeId, refreshId] }, + }, + ] + : undefined, + }; +} + +export function renderSetupSummary(opts: { + cwd: string; + bundle: OwnedGuidelinesBundleResult; + gaps: SetupGaps; + dagPath: string; +}): string { + const { cwd, bundle, gaps, dagPath } = opts; + const bundleRef = normalizeRelativePath(cwd, bundle.bundlePath); + const manifestRef = normalizeRelativePath(cwd, bundle.manifestPath); + const dagRef = normalizeRelativePath(cwd, dagPath); + const gapLines = setupGapLines(gaps); + + const lines: string[] = [ + '# Proof Setup Summary', + '', + `- **Owned guidelines bundle:** \`${bundleRef}\` (${bundle.status})`, + `- **Owned guidelines manifest:** \`${manifestRef}\``, + `- **Generated setup DAG:** \`${dagRef}\``, + `- **Freshness check before this run:** ${ + bundle.freshness.isFresh ? 'fresh' : bundle.freshness.reasons.join('; ') + }`, + '', + '## Guidance Sources', + '', + ]; + + for (const source of bundle.manifest.sources) { + lines.push(`- \`${source.path}\` (${source.category})`); + } + if (bundle.manifest.sources.length === 0) { + lines.push( + '- _No existing owned Proof guideline sources were discovered._' + ); + } + + lines.push('', '## Gaps', ''); + for (const line of gapLines) { + lines.push(`- ${line}`); + } + + lines.push( + '', + '## Maintenance Contract', + '', + `- ${PROOF_SETUP_UPDATE_DIRECTIVE}` + ); + lines.push( + '- Default `proof setup` only refreshes/reuses the owned bundle, computes gaps, and writes the DAG/summary.', + '- When setup gaps exist, the generated DAG inserts an explicit `proof setup` refresh step after corrective edits so review reads regenerated owned-guidelines artifacts instead of stale pre-edit files.', + '- Use `proof setup --run-agents` to hand the generated DAG to the existing Proof runner.' + ); + + return lines.join('\n').trimEnd() + '\n'; +} diff --git a/packages/proof/tsup.config.ts b/packages/proof/tsup.config.ts index de20a35e..3a1a507a 100644 --- a/packages/proof/tsup.config.ts +++ b/packages/proof/tsup.config.ts @@ -6,6 +6,7 @@ export const tsup: Options = { clean: true, entryPoints: [ 'src/index.ts', + 'src/setup.ts', 'src/run_dag.ts', 'src/run_dag_supervisor.ts', 'src/list_models.ts', diff --git a/packages/source-filesystem/README.md b/packages/source-filesystem/README.md index 681ab87b..800e06f7 100644 --- a/packages/source-filesystem/README.md +++ b/packages/source-filesystem/README.md @@ -1,6 +1,6 @@ # @flatbread/source-filesystem 🗃 -> Transform files into content that can be fetched with GraphQL. +> Load files into Flatbread's relational content model (often queried via GraphQL in the default toolkit). ## 💾 Install diff --git a/packages/transformer-markdown/README.md b/packages/transformer-markdown/README.md index 59d78b4a..bf08b264 100644 --- a/packages/transformer-markdown/README.md +++ b/packages/transformer-markdown/README.md @@ -1,6 +1,6 @@ # @flatbread/transformer-markdown ⚡ -> Transform [Markdown](https://en.wikipedia.org/wiki/markdown) files into content that can be fetched with GraphQL. If you're using a CMS like NetlifyCMS, you'll want to pair this with the [`source-filesystem`](https://github.com/FlatbreadLabs/flatbread/blob/main/packages/source-filesystem/README.md) plugin. +> Transform [Markdown](https://en.wikipedia.org/wiki/markdown) into Flatbread collection entries. Pair with [`source-filesystem`](https://github.com/FlatbreadLabs/flatbread/blob/main/packages/source-filesystem/README.md) when content lives on disk; typical setups then read the graph via GraphQL. ## 💾 Install diff --git a/packages/transformer-yaml/README.md b/packages/transformer-yaml/README.md index 7b331bfd..35bd0625 100644 --- a/packages/transformer-yaml/README.md +++ b/packages/transformer-yaml/README.md @@ -1,6 +1,6 @@ # @flatbread/transformer-yaml 🐪 -> Transform [YAML](https://en.wikipedia.org/wiki/YAML) files into content that can be fetched with GraphQL. +> Transform [YAML](https://en.wikipedia.org/wiki/YAML) into Flatbread collection entries (often consumed through GraphQL in the default setup). ## 💾 Install @@ -16,27 +16,16 @@ Pair this with a compatible source plugin in your `flatbread.config.js` file: ```js // flatbread.config.js -import defineConfig from '@flatbread/config'; -import transformer from '@flatbread/transformer-markdown'; -import filesystem from '@flatbread/source-filesystem'; +import { defineConfig, sourceFilesystem, transformerMarkdown } from 'flatbread'; +import transformerYaml from '@flatbread/transformer-yaml'; export default defineConfig({ - source: filesystem(), - transformer: transformer(), + source: sourceFilesystem(), + transformer: [transformerMarkdown(), transformerYaml()], content: [ { - path: 'content/posts', - collection: 'Post', - refs: { - authors: 'Author', - }, - }, - { - path: 'content/authors', - collection: 'Author', - refs: { - friend: 'Author', - }, + path: 'content/yaml/posts', + collection: 'YamlPost', }, ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52872d85..b3cfc9d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,7 +98,7 @@ importers: dependencies: '@graphql-typed-document-node/core': specifier: ^3.2.0 - version: 3.2.0(graphql@16.11.0) + version: 3.2.0(graphql@16.14.0) next: specifier: 15.4.4 version: 15.4.4(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.89.2) @@ -5609,6 +5609,10 @@ packages: resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + graphql@16.14.0: + resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + graphql@16.5.0: resolution: {integrity: sha512-qbHgh8Ix+j/qY+a/ZcJnFQ+j8ezakqPiHwPiZhV/3PgGlgf96QMBB5/f2rkiC9sgLoy/xvT6TSiaf2nTHJh5iA==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -10642,9 +10646,9 @@ snapshots: graphql: 16.5.0 tslib: 2.8.1 - '@graphql-typed-document-node/core@3.2.0(graphql@16.11.0)': + '@graphql-typed-document-node/core@3.2.0(graphql@16.14.0)': dependencies: - graphql: 16.11.0 + graphql: 16.14.0 '@graphql-typed-document-node/core@3.2.0(graphql@16.5.0)': dependencies: @@ -14587,6 +14591,8 @@ snapshots: graphql@16.11.0: {} + graphql@16.14.0: {} + graphql@16.5.0: {} gray-matter@4.0.3: