Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
757903d
fix(harness): overnight strictness findings F2–F21 from aeon-qwen3.6-…
agjs Jun 24, 2026
18df21e
feat(scaffold): boringstack wizard core + clone→configure→boot I/O layer
agjs Jun 24, 2026
3fec33d
test(scaffold): gated real clone+configure E2E against a BoringStack …
agjs Jun 24, 2026
218d8fe
feat(scaffold): non-interactive flags→answers parser (parseScaffoldArgs)
agjs Jun 24, 2026
afcb58a
feat(scaffold): bundled manifest loader + headless scaffold entry
agjs Jun 24, 2026
e016324
feat(scaffold): consequence preview (topology + required secrets + vi…
agjs Jun 24, 2026
78b6249
feat(scaffold): interactive 'tsforge scaffold' subcommand
agjs Jun 24, 2026
4ebfb50
test(scaffold): exhaustive capability on/off matrix
agjs Jun 24, 2026
f56f6a3
fix(lint): clean up the overnight findings commit so it passes the gate
agjs Jun 24, 2026
7cfcc2c
fix(test): make the boot + proptest oracle tests deterministic (suite…
agjs Jun 24, 2026
5c03e16
fix(feedback): steer the model off branded-ID types under the no-`as`…
agjs Jun 24, 2026
96d8695
docs(scaffold): document the BoringStack greenfield scaffolder
agjs Jun 24, 2026
c76e7ca
fix(ci): regenerate rules catalog + allowlist scaffold-manifest in gi…
agjs Jun 24, 2026
0fe8181
fix(ci): module-exports readdir Dirent typing under newer @types/node
agjs Jun 24, 2026
3c51072
fix(review): address gemini-code-assist findings on the PR
agjs Jun 24, 2026
86b75bb
fix(scaffold): address Codex PR review (P1×3, P2, P3)
agjs Jun 24, 2026
16da18b
fix(scaffold): clone manifest is the ONLY source of truth + de-binary…
agjs Jun 24, 2026
15e188a
fix(loop): force a gate on edit-churn so a never-yielding model can't…
agjs Jun 24, 2026
55c1737
fix(edit): offer the create-rewrite escape hatch on not-found for AUT…
agjs Jun 24, 2026
8a4c075
fix(prompt): steer off branded IDs in the always-on build rules (F26)
agjs Jun 24, 2026
5200e68
fix(loop): guard the churn force-gate on hasGate (Codex P1)
agjs Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@
useDefault = true

[allowlist]
description = "Test fixtures that exercise secret redaction — not real credentials."
description = "Test fixtures that exercise secret redaction, and the scaffold manifest (declares which secret KEYS a config requires — never their values)."
paths = [
'''packages/core/tests/session-store\.test\.ts''',
'''packages/core/tests/ledger\.test\.ts''',
# The scaffold manifest names required-secret KEYS + cross-field gating tokens
# (e.g. requiresSecretsWhen: "AI_ENABLED=true") — no real credentials. The
# "Secret" in the field name trips the generic-api-key heuristic.
'''packages/core/src/scaffold/scaffold-manifest\.json''',
'''packages/core/tests/fixtures/scaffold/scaffold-manifest\.json''',
]
3 changes: 2 additions & 1 deletion apps/docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@ export default defineConfig({
{ label: "Safer line edits", link: "/uplift/hashline/" },
{ label: "Stopping bad output early", link: "/uplift/ttsr/" },
{ label: "Learning from past runs", link: "/uplift/memory/" },
{ label: "Web scaffolding", link: "/scaffold/web/" },
{ label: "Greenfield scaffolding", link: "/scaffold/boringstack/" },
{ label: "Web scaffolding (legacy)", link: "/scaffold/web/" },
{ label: "Model adapter", link: "/inference/adapter/" },
{ label: "Token metrics", link: "/observability/metrics/" },
{ label: "Trace a run", link: "/observability/trace/" },
Expand Down
14 changes: 14 additions & 0 deletions apps/docs/src/content/docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ bun run tsforge

Environment variables: [Environment variables](/reference/flags/). Interactive usage: [Interactive CLI](/cli/interactive/).

## Scaffold a new project

```bash
tsforge scaffold --dest ./my-app # full BoringStack (interactive)
tsforge scaffold --dest ./site --archetype astro # Astro static site
tsforge scaffold --dest ./my-app --set WITH_OBSERVABILITY=0 --no-boot
```

`tsforge scaffold` stands up a new project from [BoringStack](https://boringstack.xyz) —
clone → configure (via BoringStack's own scripts) → boot → hands off the build gate.
Interactively it previews the container topology + required secrets before applying.
Greenfield only; editing an existing repo never scaffolds. See
[Greenfield scaffolding](/scaffold/boringstack/).

## Set up a repo

```bash
Expand Down
114 changes: 114 additions & 0 deletions apps/docs/src/content/docs/scaffold/boringstack.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
title: Greenfield scaffolding
description: Stand up a new project from BoringStack (or its Astro static site) with the tsforge scaffold wizard.
---

Use this when you want a **new project from scratch**. Instead of letting the model
invent a stack — or tsforge synthesizing one (the legacy [Web scaffolding](/scaffold/web/)) —
the wizard stands up [**BoringStack**](https://boringstack.xyz), a proven full-stack
TypeScript template, by driving BoringStack's **own** setup scripts. tsforge holds no
stack knowledge of its own: the entire config surface is declared in a manifest
committed in the BoringStack repo, which tsforge reads after cloning.

## Two archetypes

| Archetype | What you get | Gate |
| --- | --- | --- |
| `boringstack` | The full stack: Bun + Elysia + Drizzle API (`apps/api`) + Vite/React UI (`apps/ui`), Postgres + Valkey, and whatever observability/billing/auth services your toggles enable | `cd apps/api && bun run validate` · `cd apps/ui && bun run validate` · `bun run check` |
| `astro` | The Astro Starlight static site (`apps/docs`) — no API, no Docker, no `.env` | `bun run build` |

## When it runs

Greenfield only. Editing an existing repo never scaffolds — tsforge auto-detects the
gate and patches in place. The wizard is its own subcommand so its flags don't collide
with the harness flags:

```bash
tsforge scaffold --dest ./my-app
```

Interactively (a TTY), it asks the archetype's questions and shows a live preview of
the **consequence** before anything is written: the container topology (the 5-vs-20
service difference your toggles make), the secrets you'll need to supply, and any
blocking cross-rule violation. Off a TTY, it uses flags directly.

## What it does, in order

1. **Clone** BoringStack at the manifest's `defaultRef`, resolving the exact commit
SHA into `<project>/.tsforge/scaffold.json` for replay.
2. **Configure** by driving BoringStack's own scripts — `scripts/rename-project.sh`,
then `setup.sh` (which bootstraps `compose/.env` and generates a GlitchTip secret) —
then writing your toggle/provider answers into the right `.env` file (infra toggles
→ `infra/compose/compose/.env`; app features → `infra/compose/compose/api.<stack>.env`).
Prod-only secrets (`JWT_SECRET`, `MFA_ENCRYPTION_KEY`, `VALKEY_PASSWORD`) are
generated; secret values are written to disk but **never** logged.
3. **Boot** the stack with `setup.sh --up` and health-poll the API + UI (skip with
`--no-boot` / `STACK=smoke`). Boot is a one-time scaffold-step, not part of the
per-edit gate.
4. **Hand off** — prints the exact command to start building features against the
scaffolded project (its own `bun run validate` becomes the gate).

## Flags

| Flag | Meaning |
| --- | --- |
| `--dest <dir>` | Where to create the project (required) |
| `--archetype <boringstack\|astro>` | Default `boringstack` |
| `--stack <dev\|prod\|smoke>` | Default `dev` |
| `--set KEY=VALUE` | Set a toggle/provider answer (repeatable) — e.g. `--set WITH_OBSERVABILITY=0` |
| `--multi KEY=a,b` | Set a multi-select answer — e.g. `--multi OAUTH_PROVIDERS=google,github` |
| `--ref <git-ref>` | Override the manifest's clone ref |
| `--no-boot` | Clone + configure, but don't start Docker |

After it finishes:

```bash
tsforge --dir ./my-app --accept '(cd apps/api && bun run validate) && (cd apps/ui && bun run validate) && bun run check' "add a deals resource end-to-end"
```

## The manifest (single source of truth)

tsforge models none of BoringStack's surface itself. `.tsforge/scaffold-manifest.json`
**in the BoringStack repo** declares every toggle, provider choice, secret, the
services each toggle spawns, cross-rules (OAuth ⇒ Valkey, `EMAIL_PROVIDER=smtp` ⇒
`WITH_MAILPIT`, OTel-vs-Sentry exclusion), and which `.env` file each value targets.
tsforge reads it post-clone and a **completeness alarm** fails the build if a watched
`WITH_*`/`*_ENABLED` toggle in BoringStack's `.env.example` isn't modelled — so the
wizard can never silently drop a capability. Evolving BoringStack means editing that
manifest, not tsforge.

## How to test it

**Unit (fast, no clone/Docker)** — the planner, manifest parser, wizard flow, env
apply, and completeness alarm are pure and fully covered:

```bash
bun test packages/core/tests/scaffold-*.test.ts
```

This includes a snapshot of BoringStack's real `.env.example` files asserting the
bundled manifest has zero coverage gaps, plus an exhaustive on/off matrix for every
toggle and provider.

**Real clone + configure (opt-in, needs a local BoringStack checkout)** — runs git +
BoringStack's actual `setup.sh`, no Docker boot:

```bash
TSFORGE_SCAFFOLD_E2E=1 BORINGSTACK_REPO=/path/to/boringstack \
bun test packages/core/tests/scaffold-clone-configure.e2e.test.ts
```

**End-to-end by hand** — drive the whole flow against a local checkout (the
`BORINGSTACK_REPO` override avoids hitting GitHub):

```bash
BORINGSTACK_REPO=/path/to/boringstack \
bun packages/core/scripts/headless-scaffold-build.ts \
--dest /tmp/acme --set WITH_OBSERVABILITY=0 --multi OAUTH_PROVIDERS=google --no-boot
```

It prints the cloned SHA, the configured `.env` (secrets redacted), the composed gate
command, and the next step. A full boot (omit `--no-boot`) additionally health-checks
the running stack.

→ [Greenfield loop](/loop/greenfield/) · [How tsforge builds the gate](/loop/gate-floor/)
10 changes: 9 additions & 1 deletion apps/docs/src/content/docs/scaffold/web.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
---
title: Web scaffolding
title: Web scaffolding (legacy)
description: Bootstrapping a new Vite app inside a tsforge session.
---

:::caution[Legacy path — being retired]
This synthesizes a Vite/React app from tsforge's own templates. It is superseded by
[Greenfield scaffolding](/scaffold/boringstack/), which stands up the proven
BoringStack template instead of inventing a stack. `--web` still works today but is
slated for removal once the eval paths migrate off it. For new projects, prefer
`tsforge scaffold`.
:::

Use this when you want tsforge to **create a new browser app from scratch**, not when you are editing an existing codebase.

Instead of letting the model invent folder structure and config, tsforge materializes a known-good Vite stack, installs dependencies, and switches to a stricter **web gate** so "done" means the app actually builds and renders.
Expand Down
7 changes: 4 additions & 3 deletions packages/core/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Rules are grouped by **adoption tier**. Use `profile` in `tsforge.config.json` t
- **security/no-spawn-with-shell** [ERROR]: Disallow child_process.spawn/spawnSync with shell: true — shell execution enables command injection.
- **typescript-core/fetch-must-check-ok** [ERROR]: HTTP fetch responses must check `.ok` or status before calling `.json()`.
- **typescript-core/json-parse-must-validate** [ERROR]: Disallow bare JSON.parse on untrusted input — validate through a schema library.
- **typescript-core/no-self-import** [ERROR]: Disallow a module importing or re-exporting from itself (a circular self-reference whose binding doesn't exist).
- **typescript-core/no-unsafe-boundary-cast** [ERROR]: Disallow type assertions immediately after parsing untrusted boundary input.

### Tier: framework
Expand Down Expand Up @@ -101,11 +102,11 @@ Rules are grouped by **adoption tier**. Use `profile` in `tsforge.config.json` t
- **react-component-architecture/dangerous-html-requires-sanitize** [ERROR]: dangerouslySetInnerHTML requires a sanitization library (DOMPurify or equivalent) imported in the same file.
- **react-component-architecture/forwardref-display-name** [ERROR]: forwardRef components must have displayName set
- **react-component-architecture/index-must-reexport-default** [ERROR]: index.ts in component folders must re-export the component default export and types
- **react-component-architecture/max-hooks-per-file** [WARN]: Flag query/hook modules that export more than N hooks. Same-kind modules pass the single-semantic-module rule but still grow into god files; this rule sets a hard ceiling so the split conversation happens early.
- **react-component-architecture/no-anonymous-useEffect** [WARN]: Disallow anonymous arrow functions passed to useEffect — use a named function for debuggable stack traces.
- **react-component-architecture/max-hooks-per-file** [ERROR]: Flag query/hook modules that export more than N hooks. Same-kind modules pass the single-semantic-module rule but still grow into god files; this rule sets a hard ceiling so the split conversation happens early.
- **react-component-architecture/no-anonymous-useEffect** [ERROR]: Disallow anonymous arrow functions passed to useEffect — use a named function for debuggable stack traces.
- **react-component-architecture/no-component-invocation** [ERROR]: Disallow invoking React components as plain functions — use JSX (`<Header />`) instead of `{Header()}`.
- **react-component-architecture/no-cross-feature-imports** [ERROR]: Prevent imports across different features
- **react-component-architecture/no-derived-state-in-effect** [WARN]: Disallow setting local state inside useEffect when the value can be derived during render (or memoized with useMemo).
- **react-component-architecture/no-derived-state-in-effect** [ERROR]: Disallow setting local state inside useEffect when the value can be derived during render (or memoized with useMemo).
- **react-component-architecture/no-jsx-computation** [ERROR]: Move complex computations out of JSX into hooks or helper functions
- **react-component-architecture/no-loading-text-use-skeleton** [ERROR]: Loading states must render a <Skeleton/>, not loading text or a spinner
- **react-component-architecture/no-nested-component** [ERROR]: Disallow declaring React components inside another component body — nested components reset state on every parent render.
Expand Down
17 changes: 14 additions & 3 deletions packages/core/scripts/boot-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,31 @@ export function bootConfig(
};
}

/** A minimal fetch shape — just the status — so the poller can be driven with a
* fake in tests without touching the network. */
type IFetchLike = (
url: string,
init: { signal: AbortSignal }
) => Promise<{ status: number }>;

/** Poll `url` until it answers with status < 500, or the deadline passes.
* Returns the status code on success, or null on timeout. */
* Returns the status code on success, or null on timeout. `now`/`sleep`/`fetchFn`
* are injectable so the timeout path is unit-testable with a fake clock AND a
* fake fetch — otherwise a real `fetch` to a dead URL can hang up to its abort
* timeout per poll (flaky when the host drops rather than refuses the connection). */
export async function pollUntilReady(
url: string,
timeoutMs: number,
now: () => number = () => performance.now(),
sleep: (ms: number) => Promise<void> = (ms) =>
new Promise((r) => setTimeout(r, ms))
new Promise((r) => setTimeout(r, ms)),
fetchFn: IFetchLike = fetch
): Promise<number | null> {
const deadline = now() + timeoutMs;

while (now() < deadline) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
const res = await fetchFn(url, { signal: AbortSignal.timeout(2000) });

if (res.status < 500) {
return res.status;
Expand Down
22 changes: 16 additions & 6 deletions packages/core/scripts/headless-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
makeFileLinter,
scaffoldWeb,
webGuidance,
WEB_PACKS,
} from "../src/detect-gate";
import { OpenAICompatibleProvider, PROVIDER_LIMITS } from "../src/inference";
import { resolveActiveModel, resolveApiKey } from "../src/models-config";
Expand Down Expand Up @@ -129,7 +130,7 @@ async function runPlanned(
const designed = await session.designBuild(
prompt,
{},
buildWebTypeGate(framework).command
buildWebTypeGate(framework, undefined, dir).command
);

if (designed.status === "interrupted") {
Expand Down Expand Up @@ -209,6 +210,9 @@ async function main(): Promise<void> {
model: entry.model,
apiKey: resolveApiKey(entry),
maxTokens: entry.maxTokens ?? PROVIDER_LIMITS.maxTokens,
// Unattended build: ride out a model-server restart (the local Spark bouncing)
// for up to 3 min rather than failing the whole run on a transient drop.
connectRetryMs: 180_000,
});

const report = makeReporter(logFile, agentLog);
Expand All @@ -235,17 +239,23 @@ async function main(): Promise<void> {
// so the model can't satisfice on a subset (4-of-8 entities greened before).
accept:
entities.length > 0
? `${buildWebGate(framework).command} && bun "${join(import.meta.dir, "coverage-check.ts")}" "${dir}" ${entities
? `${buildWebGate(framework, undefined, dir).command} && bun "${join(import.meta.dir, "coverage-check.ts")}" "${dir}" ${entities
.map((e) => JSON.stringify(e))
.join(" ")}`
: buildWebGate(framework).command,
: buildWebGate(framework, undefined, dir).command,
fix: buildWebFix(framework),
incrementalCheck: buildWebTscCheck(),
incrementalCheck: buildWebTscCheck(dir),
// WRITE-TIME LINT: surface the gate's eslint moat rules (no-as, I-prefix,
// prefer-template) on each file the instant it's written — tsc can't see them,
// so without this they pile up unseen until the gate (a run log showed 12 `as`
// casts accumulating that way). cwd = the run dir so vendored ignores resolve.
lintFile: makeFileLinter(framework, dir),
// Pass WEB_PACKS so write-time lint enforces the SAME rules the gate does —
// incl. the react-component-architecture moat (no inline helpers/types/consts
// in a component, extract computations). Without it those rules were inert at
// write time and only detonated at the gate as a 20-violation avalanche, the
// exact end-of-build cascade the write-guard exists to prevent. (cli.ts and
// interactive-eval.ts already pass WEB_PACKS; the headless/eval path didn't.)
lintFile: makeFileLinter(framework, dir, WEB_PACKS),
// Offer the themed-UI-primitives tool so the model generates button/card/input/
// etc. (tested, theme-coherent) instead of re-authoring them every build.
scaffoldUi: framework === "react",
Expand Down Expand Up @@ -274,7 +284,7 @@ async function main(): Promise<void> {
: await session.buildStaged(
prompt,
{},
buildWebTypeGate(framework).command
buildWebTypeGate(framework, undefined, dir).command
);

// The run dir IS the persistent, runnable artifact (per-run, never clobbered):
Expand Down
82 changes: 82 additions & 0 deletions packages/core/scripts/headless-scaffold-build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Non-interactive scaffold entry — the eval/automation seam for the boringstack
// wizard. Parses flags → answers, clones boringstack at the manifest ref,
// configures it via boringstack's own scripts, optionally boots the stack, and
// prints the handoff (where + how the harness then runs the gate).
//
// Mirrors the interactive `--scaffold` wizard's output (answers → runScaffold) so
// the two paths share one engine. The model-driven build loop is a separate step
// (hand `gateCwd` + `gateCommand` to the greenfield loop) — wired by the CLI.
//
// bun headless-scaffold-build.ts --dest /tmp/acme \
// --set project=acme --set ghcrOwner=acme-corp --set domain=acme.com \
// --set WITH_OBSERVABILITY=0 --multi OAUTH_PROVIDERS=google --no-boot
//
// Secret VALUES are never printed (org rule); the summary shows keys only.

import { loadBundledManifest } from "../src/scaffold/boringstack-manifest";
import { parseScaffoldArgs } from "../src/scaffold/scaffold-cli";
import { runScaffold } from "../src/scaffold/run-scaffold";
import { realRunner, realFs, realPoller } from "../src/scaffold/io";
import type { IScaffoldManifest } from "../src/scaffold/scaffold.types";

/** Apply a `--ref` override and the `BORINGSTACK_REPO` env override (the latter
* lets dev/E2E clone a local checkout instead of GitHub). */
function withOverrides(
manifest: IScaffoldManifest,
ref: string
): IScaffoldManifest {
const repo = process.env.BORINGSTACK_REPO;

return {
...manifest,
...(ref.length > 0 ? { defaultRef: ref } : {}),
...(repo !== undefined && repo.length > 0 ? { repo } : {}),
};
}

async function main(): Promise<number> {
const opts = parseScaffoldArgs(process.argv.slice(2));
const manifest = withOverrides(loadBundledManifest(), opts.ref);

process.stdout.write(
`scaffold: ${opts.answers.archetype} (${opts.answers.stack}) → ${opts.dest}\n` +
` repo ${manifest.repo}@${manifest.defaultRef}${opts.skipBoot ? " [--no-boot]" : ""}\n`
);

const outcome = await runScaffold(manifest, opts.answers, opts.dest, {
run: realRunner,
fs: realFs,
boot: { poll: realPoller },
skipBoot: opts.skipBoot,
});

process.stdout.write(
[
"",
`cloned ${outcome.resolvedSha}`,
`booted ${String(outcome.booted)}${outcome.bootError === undefined ? "" : ` (${outcome.bootError})`}`,
`gate (cd ${outcome.gateCwd})`,
` ${outcome.gateCommand}`,
"",
"configured .env:",
...outcome.summary.map((l) => ` ${l}`),
"",
"next: run the greenfield loop against gateCwd with gateCommand as the gate.",
"",
].join("\n")
);

// A boringstack boot that was requested but didn't come up is a hard failure.
return !opts.skipBoot && outcome.bootError !== undefined ? 1 : 0;
}

if (import.meta.main) {
main()
.then((code) => process.exit(code))
.catch((err: unknown) => {
process.stderr.write(
`scaffold failed: ${err instanceof Error ? err.message : String(err)}\n`
);
process.exit(1);
});
}
Loading
Loading