Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on Keep a Changelog and follows Semantic Versioning.

### Fixed

- **`@stackbilt/scaffold-core` generated project now typechecks clean out of the box** (`stackbilt-web#151` follow-through) — E2E production test (generate → `npm install` → `tsc --noEmit`) caught three residual template defects after the traitMap fix: (1) generated `tsconfig.json` omitted `lib`, so tsc injected `lib.dom` which collides with `@cloudflare/workers-types` globals (~40 declaration-conflict errors) — now pins `lib: ["ES2022"]` + `skipLibCheck: true`, matching the materializer's `renderTsConfig`; (2) `HttpError.status` was `number` but Hono's `c.json(body, status)` requires `ContentfulStatusCode`, so the generated `app.onError` handler failed typecheck — now typed as `ContentfulStatusCode`; (3) route templates' `.json<T>().catch(() => ({}))` widened the body union to `{}`, breaking property access under `strict` — catch fallbacks now typed as `{} as T`. Regression guards added to `output-quality.test.ts`.
- **`@stackbilt/scaffold-core` hero-demo output quality** (`tarotscript#427`, `stackbilt-web#151`) — `classify()` computed a `traitMap` (route_shape, default_routes, source_pattern, etc.) per matched pattern but never attached it to `ClassifyResult`; codegen fell back to parsing `key:value` pairs out of the human-readable `traits` array, which never contained colons. As a result `src/worker.ts` registered zero routes for every scaffold, the `cron-worker` scheduled-handler wrapper and `[triggers]` wrangler stanza never fired, webhook codegen always emitted Stripe signature verification even for generic/GitHub-style webhooks, and D1 schema selection silently fell back to the generic `resources` table for every "worker"-bucketed pattern (Stripe webhook, generic webhook, hardening overlay, AI chat all share the coarse `PatternName` `'worker'`). `ClassifyResult` and `ScaffoldFacts` now carry `traitMap` and a `sourcePattern` field; all codegen/knowledge/wrangler lookups key off `sourcePattern` instead of the coarse `pattern` bucket. Also fixed: `inferBindings()` was being called with the coarse pattern name instead of the actual intention text, so D1/KV/R2/AI binding inference silently never ran (masked by a universal D1+KV fallback whenever no signal was detected).
- **`@stackbilt/scaffold-core` project naming** (`tarotscript#427`) — `projectName` defaulted to the literal placeholder `'my-worker'`, and `wrangler.toml`/`package.json` had separate hardcoded names (`stackbilder-generated` / `stackbilder-scaffold`) that never matched each other or the contract filename. Added `deriveProjectSlug()` — derives a kebab-case slug (capped at 40 chars, falls back to `scaffold-app`) from the intention when no explicit `projectName` option is passed — and threaded the single resulting slug through wrangler.toml, package.json, the contract filename/description, and all `.ai/*.adf` headers.
- **`@stackbilt/scaffold-core` empty/broken generated files** (`tarotscript#427`) — `.ai/adr-002.md` was always emitted, including as a 0-byte file when there were no compliance domains (`governance.adr002 ?? ''`); it is now omitted entirely when there's nothing to say. `.ai/core.adf` and `.ai/state.adf` rendered `### ` headings with blank bullet fields (no upstream rawFacts exist in the local scaffold-core flow); sections now render only when real content exists, with an honest fallback note otherwise.
Expand Down
26 changes: 26 additions & 0 deletions packages/scaffold-core/src/__tests__/output-quality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,29 @@ describe('scaffold-core output quality — every generated import is a declared
}
});
});

describe('scaffold-core output quality — generated project typechecks clean', () => {
it.each(REPRESENTATIVE_INTENTIONS)('tsconfig pins lib to ES2022 (no lib.dom clash with workers-types) for: %s', (intention) => {
const result = buildScaffold(intention);
const tsconfig = JSON.parse(fileContent(result, 'tsconfig.json'));
expect(tsconfig.compilerOptions.lib).toEqual(['ES2022']);
expect(tsconfig.compilerOptions.skipLibCheck).toBe(true);
});

it.each(REPRESENTATIVE_INTENTIONS)('HttpError.status is a ContentfulStatusCode so onError c.json(err.status) typechecks for: %s', (intention) => {
const result = buildScaffold(intention);
const httpError = fileContent(result, 'src/lib/http-error.ts');
if (!httpError) return; // pattern without the http-error helper
expect(httpError).toContain('ContentfulStatusCode');
expect(httpError).not.toMatch(/status: number/);
});

it.each(REPRESENTATIVE_INTENTIONS)('json body catch fallbacks are typed to the request shape for: %s', (intention) => {
const result = buildScaffold(intention);
for (const f of result.files) {
// Bare `.catch(() => ({}))` widens the body union to {} and breaks
// property access under strict mode in the downloaded project.
expect(f.content).not.toContain('.catch(() => ({}))');
}
});
});
10 changes: 8 additions & 2 deletions packages/scaffold-core/src/codegen/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,11 @@ export function baseFiles(facts: ScaffoldFacts): ScaffoldFile[] {
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'Bundler',
// Without an explicit lib, tsc injects lib.dom, which conflicts
// with @cloudflare/workers-types globals (Response, caches, ...).
lib: ['ES2022'],
strict: true,
skipLibCheck: true,
types: ['@cloudflare/workers-types', 'vitest/globals'],
},
include: ['src', 'tests'],
Expand Down Expand Up @@ -505,10 +509,12 @@ export function baseFiles(facts: ScaffoldFacts): ScaffoldFile[] {
path: 'src/lib/http-error.ts',
role: 'entry',
content: [
'import type { ContentfulStatusCode } from "hono/utils/http-status";',
'',
'export class HttpError extends Error {',
' status: number;',
' status: ContentfulStatusCode;',
'',
' constructor(status: number, message: string) {',
' constructor(status: ContentfulStatusCode, message: string) {',
' super(message);',
' this.status = status;',
' }',
Expand Down
8 changes: 4 additions & 4 deletions packages/scaffold-core/src/codegen/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export function routeContent(
`export function ${fnName}(app: Hono<{ Bindings: Env }>) {`,
' app.post("/chat", async (c) => {',
' await requireBearerAuth(c);',
' const body = await c.req.json<{ message?: string }>().catch(() => ({}));',
' const body = await c.req.json<{ message?: string }>().catch(() => ({} as { message?: string }));',
' if (!body.message) throw new HttpError(400, "message is required");',
'',
' const userContent = sanitizeUserContent(body.message);',
Expand Down Expand Up @@ -228,7 +228,7 @@ export function routeContent(
'',
`export function ${fnName}(app: Hono<{ Bindings: Env }>) {`,
' app.post("/auth/login", async (c) => {',
' const body = await c.req.json<{ email?: string }>().catch(() => ({}));',
' const body = await c.req.json<{ email?: string }>().catch(() => ({} as { email?: string }));',
' if (!body.email) throw new HttpError(400, "email is required");',
' return c.json({ token: "replace-with-jwt", email: body.email });',
' });',
Expand Down Expand Up @@ -271,7 +271,7 @@ export function routeContent(
'',
' app.post("/resources", async (c) => {',
' await requireBearerAuth(c);',
' const body = await c.req.json<{ name?: string }>().catch(() => ({}));',
' const body = await c.req.json<{ name?: string }>().catch(() => ({} as { name?: string }));',
' if (!body.name || typeof body.name !== "string") throw new HttpError(400, "name is required");',
' const id = crypto.randomUUID();',
' await c.env.DB.prepare(',
Expand Down Expand Up @@ -305,7 +305,7 @@ export function routeContent(
' app.put("/resources/:id", async (c) => {',
' await requireBearerAuth(c);',
' const id = c.req.param("id");',
' const body = await c.req.json<{ name?: string }>().catch(() => ({}));',
' const body = await c.req.json<{ name?: string }>().catch(() => ({} as { name?: string }));',
' if (!body.name) throw new HttpError(400, "name is required");',
' const result = await c.env.DB.prepare(',
' "UPDATE resources SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",',
Expand Down