diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c4620..34fc1c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on Keep a Changelog and follows Semantic Versioning. ## [Unreleased] +### Fixed + +- **`@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. +- **`@stackbilt/scaffold-core` generated dependency gaps** (`tarotscript#427`) — the generated `src/contracts/*.contract.ts` stub imports `zod` and `@stackbilt/contracts`, but the base `package.json` (generated before the contract file exists) only declared `hono` — so a downloaded scaffold survived `npm install` and then failed typecheck/build with missing modules. Both dependencies are now backfilled onto `package.json` whenever a contract file is emitted, using the current `@stackbilt/contracts` version (bumped the stale `^0.2.1` pin in `FIRST_PARTY_DEPS` to `^0.8.0` — that package is published, contrary to the original "unpublished" premise). Also fixed: the contract template omitted the `surfaces` field whenever no API/DB surface was detected, but `@stackbilt/contracts`' `ContractDefinition.surfaces` is a required field in the current published version — every generated contract now always emits `surfaces: {}` at minimum. Disabled the `@stackbilt/worker-observability` injection in `FIRST_PARTY_DEPS` (that package genuinely 404s on the npm registry — same defect class, different package) pending it actually shipping. + ## [1.7.0] - 2026-06-24 ### Added diff --git a/packages/scaffold-core/package.json b/packages/scaffold-core/package.json index 1f7f4e8..7d16eda 100644 --- a/packages/scaffold-core/package.json +++ b/packages/scaffold-core/package.json @@ -1,7 +1,7 @@ { "name": "@stackbilt/scaffold-core", "sideEffects": false, - "version": "1.7.1", + "version": "1.8.0", "description": "Zero-dependency scaffold engine core — pattern classification, knowledge, governance, codegen, and materializer", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/scaffold-core/src/__tests__/output-quality.test.ts b/packages/scaffold-core/src/__tests__/output-quality.test.ts new file mode 100644 index 0000000..4ca84f5 --- /dev/null +++ b/packages/scaffold-core/src/__tests__/output-quality.test.ts @@ -0,0 +1,226 @@ +/** + * output-quality.test.ts — regression tests for the hero scaffold demo defects + * (charter fix/scaffold-core-output-quality; see tarotscript#427, stackbilt-web#151) + * + * The stackbilder.com public hero demo calls buildScaffold() directly and ships the + * result to every visitor. These tests lock in the fixes for: + * 1. src/worker.ts always registers at least one real route (traitMap['default_routes'] + * was being dropped between classify() and codegen, so worker.ts was always empty). + * 2. No zero-byte files (`.ai/adr-002.md` was emitted empty via `governance.adr002 ?? ''`). + * 3. Project name is derived from the intention and consistent across wrangler.toml, + * package.json, and the contract filename — no 'my-worker' / 'stackbilder-generated' + * / 'stackbilder-scaffold' placeholder remnants. + * 4. Every module imported by a generated file is declared in the generated + * package.json. `@stackbilt/contracts` IS published (0.8.0) — the original + * "unpublished package" premise from tarotscript#427 was stale — so the fix is + * NOT to drop the import. The real defect: the contract stub imports `zod` and + * `@stackbilt/contracts`, but the base package.json (generated before the + * contract file exists) only declared `hono`, so a downloaded scaffold survived + * `npm install` and then failed typecheck/build with missing modules. + */ + +import { describe, expect, it } from 'vitest'; +import { buildScaffold, deriveProjectSlug } from '../index'; +import type { LocalScaffoldResult, ScaffoldFile } from '../index'; + +function fileContent(result: LocalScaffoldResult, path: string): string { + const f = result.files.find((f: ScaffoldFile) => f.path === path); + return f?.content ?? ''; +} + +// A representative cross-section of patterns — including the exact hero-demo +// intention (SaaS billing dashboard) plus a Stripe webhook and a cron worker, +// which exercise the two source-pattern-specific codegen branches (webhook +// signature verification flavor, wrangler cron trigger) that were also silently +// broken by the same underlying bug. +const REPRESENTATIVE_INTENTIONS = [ + 'SaaS billing dashboard with Stripe integration, user management, usage analytics', + 'Build a Stripe webhook handler with HMAC verification and event routing', + 'Nightly cron job that aggregates usage data daily', +]; + +describe('scaffold-core output quality — worker.ts route registration', () => { + it.each(REPRESENTATIVE_INTENTIONS)('registers at least one route for: %s', (intention) => { + const result = buildScaffold(intention); + const worker = fileContent(result, 'src/worker.ts'); + expect(worker.length).toBeGreaterThan(0); + const registeredRoutes = worker.match(/register\w+Route\(app\);/g) ?? []; + expect(registeredRoutes.length).toBeGreaterThanOrEqual(1); + // The unconditional service-metadata route is a defense-in-depth guarantee + // independent of pattern-specific route registration. + expect(worker).toMatch(/app\.get\("\/",/); + }); + + it('dispatches the Stripe-specific webhook handler for stripe-webhook, not generic HMAC', () => { + const result = buildScaffold('Build a Stripe webhook handler with HMAC verification and event routing'); + const route = fileContent(result, 'src/routes/webhook.ts'); + expect(route).toContain('stripe-signature'); + expect(route).not.toContain('x-hub-signature'); + }); + + it('dispatches the generic HMAC webhook handler for generic-webhook, not Stripe', () => { + const result = buildScaffold('GitHub webhook handler with x-hub-signature verification'); + const route = fileContent(result, 'src/routes/webhook.ts'); + expect(route).toContain('x-hub-signature'); + expect(route).not.toContain('stripe-signature'); + }); + + it('emits a [triggers] crons stanza in wrangler.toml for cron-worker scaffolds', () => { + const result = buildScaffold('Nightly cron job that aggregates usage data daily'); + const wrangler = fileContent(result, 'wrangler.toml'); + expect(wrangler).toContain('[triggers]'); + const worker = fileContent(result, 'src/worker.ts'); + expect(worker).toContain('scheduled: handleScheduled'); + }); +}); + +describe('scaffold-core output quality — no zero-byte files', () => { + it.each(REPRESENTATIVE_INTENTIONS)('has no empty files for: %s', (intention) => { + const result = buildScaffold(intention); + const empty = result.files.filter((f: ScaffoldFile) => f.content.length === 0); + expect(empty.map((f: ScaffoldFile) => f.path)).toEqual([]); + }); + + it('omits .ai/adr-002.md entirely when there are no compliance domains', () => { + const result = buildScaffold('SaaS billing dashboard with Stripe integration, user management, usage analytics'); + expect(result.facts.qualityProfile.complianceDomains).toEqual([]); + expect(result.files.some((f: ScaffoldFile) => f.path === '.ai/adr-002.md')).toBe(false); + }); + + it('emits a populated .ai/adr-002.md when compliance domains are present', () => { + const result = buildScaffold('HIPAA-compliant patient health records API'); + const adr002 = result.files.find((f: ScaffoldFile) => f.path === '.ai/adr-002.md'); + expect(adr002).toBeDefined(); + expect(adr002!.content.length).toBeGreaterThan(0); + }); +}); + +describe('scaffold-core output quality — consistent project naming', () => { + it.each(REPRESENTATIVE_INTENTIONS)('uses one consistent derived slug for: %s', (intention) => { + const result = buildScaffold(intention); + const expectedSlug = deriveProjectSlug(intention); + expect(result.facts.projectName).toBe(expectedSlug); + + const wrangler = fileContent(result, 'wrangler.toml'); + expect(wrangler).toContain(`name = "${expectedSlug}"`); + + const pkg = JSON.parse(fileContent(result, 'package.json')) as { name: string }; + expect(pkg.name).toBe(expectedSlug); + + const contract = result.files.find((f: ScaffoldFile) => f.path.startsWith('src/contracts/')); + expect(contract?.path).toBe(`src/contracts/${expectedSlug}.contract.ts`); + + // No placeholder remnants anywhere in the output. + for (const f of result.files) { + expect(f.content).not.toContain('my-worker'); + expect(f.content).not.toContain('stackbilder-generated'); + expect(f.content).not.toContain('stackbilder-scaffold'); + } + }); + + it('respects an explicit projectName option instead of deriving one', () => { + const result = buildScaffold('Any old intention', { projectName: 'custom-name' }); + expect(result.facts.projectName).toBe('custom-name'); + expect(fileContent(result, 'wrangler.toml')).toContain('name = "custom-name"'); + }); + + it('falls back to scaffold-app when the intention has no alphanumeric content', () => { + expect(deriveProjectSlug('!!! ??? ---')).toBe('scaffold-app'); + }); + + it('caps derived slugs at 40 characters', () => { + const slug = deriveProjectSlug( + 'a'.repeat(100) + ' billing dashboard with a very long description of features', + ); + expect(slug.length).toBeLessThanOrEqual(40); + }); +}); + +// Extracts the bare package name(s) referenced by import/export-from/require +// specifiers in a source file, excluding relative paths and node: builtins. +// Scoped packages (@scope/name) are returned as the full "@scope/name" — +// everything after that is a subpath import of the same package. +function importedPackageNames(content: string): string[] { + const specifiers = new Set(); + const fromRe = /(?:import|export)\s+(?:[^'";]*?\sfrom\s+)?['"]([^'"]+)['"]/g; + const requireRe = /require\(\s*['"]([^'"]+)['"]\s*\)/g; + for (const re of [fromRe, requireRe]) { + let m: RegExpExecArray | null; + while ((m = re.exec(content))) specifiers.add(m[1]!); + } + + const packages: string[] = []; + for (const spec of specifiers) { + if (spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('node:')) continue; + const parts = spec.split('/'); + packages.push(spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]!); + } + return packages; +} + +describe('scaffold-core output quality — every generated import is a declared dependency', () => { + // Class-invariant check: whatever a generated .ts file imports, the generated + // package.json must declare (deps or devDeps) — otherwise the scaffold survives + // `npm install` and only fails later at typecheck/build. Covers the whole defect + // family (today's zod/@stackbilt/contracts gap, and any future one), not just the + // specific package that was missing when this test was written. + const PATTERN_SWEEP = [ + ...REPRESENTATIVE_INTENTIONS, + 'MCP tool server exposing agent tools via SSE', + 'Real-time collaborative editor using durable objects and websockets', + 'Chatbot API using LLM streaming responses', + 'Multi-tenant SaaS API with organization-level data isolation', + 'GitHub webhook handler with x-hub-signature verification', + ]; + + it.each(PATTERN_SWEEP)('has no undeclared imports for: %s', (intention) => { + const result = buildScaffold(intention); + const pkgFile = result.files.find((f: ScaffoldFile) => f.path === 'package.json'); + expect(pkgFile).toBeDefined(); + const pkg = JSON.parse(pkgFile!.content) as { + dependencies?: Record; + devDependencies?: Record; + }; + const declared = new Set([ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.devDependencies ?? {}), + ]); + + const undeclared: string[] = []; + for (const f of result.files) { + if (!f.path.endsWith('.ts')) continue; + for (const pkgName of importedPackageNames(f.content)) { + if (!declared.has(pkgName)) undeclared.push(`${f.path} -> ${pkgName}`); + } + } + expect(undeclared).toEqual([]); + }); + + it('contract stub imports @stackbilt/contracts (published — do not strip this)', () => { + const result = buildScaffold('SaaS billing dashboard with Stripe integration, user management, usage analytics'); + const contract = result.files.find((f: ScaffoldFile) => f.path.startsWith('src/contracts/')); + expect(contract).toBeDefined(); + expect(contract!.content).toContain("import { z } from 'zod';"); + expect(contract!.content).toContain("import { defineContract } from '@stackbilt/contracts';"); + }); + + it('declares both zod and @stackbilt/contracts whenever a contract stub is emitted', () => { + const result = buildScaffold('SaaS billing dashboard with Stripe integration, user management, usage analytics'); + const hasContract = result.files.some((f: ScaffoldFile) => f.path.startsWith('src/contracts/')); + expect(hasContract).toBe(true); + const pkg = JSON.parse(fileContent(result, 'package.json')) as { dependencies?: Record }; + expect(pkg.dependencies?.zod).toBeDefined(); + expect(pkg.dependencies?.['@stackbilt/contracts']).toBeDefined(); + }); + + it('never injects the unpublished @stackbilt/worker-observability package', () => { + // Regression guard for the disabled FIRST_PARTY_DEPS entry in materializer/project.ts — + // that package 404s on the npm registry (same defect class as the tarotscript twin). + for (const intention of PATTERN_SWEEP) { + const result = buildScaffold(intention); + for (const f of result.files) { + expect(f.content).not.toContain('@stackbilt/worker-observability'); + } + } + }); +}); diff --git a/packages/scaffold-core/src/__tests__/package.test.ts b/packages/scaffold-core/src/__tests__/package.test.ts index d9e2028..2e84882 100644 --- a/packages/scaffold-core/src/__tests__/package.test.ts +++ b/packages/scaffold-core/src/__tests__/package.test.ts @@ -12,8 +12,8 @@ describe('@stackbilt/scaffold-core package metadata', () => { expect(pkg.name).toBe('@stackbilt/scaffold-core'); }); - it('version is 1.7.1', () => { - expect(pkg.version).toBe('1.7.1'); + it('version is 1.8.0', () => { + expect(pkg.version).toBe('1.8.0'); }); it('license is Apache-2.0', () => { diff --git a/packages/scaffold-core/src/__tests__/types.test.ts b/packages/scaffold-core/src/__tests__/types.test.ts index 3a1e16b..55b4e3d 100644 --- a/packages/scaffold-core/src/__tests__/types.test.ts +++ b/packages/scaffold-core/src/__tests__/types.test.ts @@ -178,6 +178,8 @@ describe('types compile', () => { it('MaterializerResult satisfies shape', () => { const facts: ScaffoldFacts = { pattern: 'worker', + sourcePattern: 'rest-api', + traitMap: {}, projectName: 'test', intention: 'build a worker', bindings: [], diff --git a/packages/scaffold-core/src/classify/classifier.ts b/packages/scaffold-core/src/classify/classifier.ts index 34c8819..7452b6d 100644 --- a/packages/scaffold-core/src/classify/classifier.ts +++ b/packages/scaffold-core/src/classify/classifier.ts @@ -41,6 +41,7 @@ export function choosePattern(intention: string): ClassifyResult { pattern: winner.name as PatternName, confidence: confidenceNum, traits: winner.traits, + traitMap: winner.traitMap, qualityProfile: { testingLevel: 'standard', observability: false, diff --git a/packages/scaffold-core/src/classify/index.ts b/packages/scaffold-core/src/classify/index.ts index f20ecf8..458c78f 100644 --- a/packages/scaffold-core/src/classify/index.ts +++ b/packages/scaffold-core/src/classify/index.ts @@ -19,13 +19,12 @@ import type { ClassifyResult } from '../types'; import { choosePattern } from './classifier'; import { extractEnrichedPrd, injectPrdSections } from './enricher'; import { inferQualityProfile } from './quality'; -import { SCORED_PATTERNS } from './patterns'; /** * Classify an intention string and return a fully-populated ClassifyResult. * * Steps: - * 1. choosePattern — vocabulary scoring → PatternName + traits + * 1. choosePattern — vocabulary scoring → PatternName + traits + traitMap * 2. extractEnrichedPrd — stack/entity detection * 3. injectPrdSections — append Stack/Entities sections to intention * 4. inferQualityProfile — compliance + feature flags @@ -33,11 +32,9 @@ import { SCORED_PATTERNS } from './patterns'; export function classify(intention: string): ClassifyResult { const base = choosePattern(intention); - // Resolve source_pattern string from the scored patterns for quality inference - const matchedScored = SCORED_PATTERNS.find( - (p) => p.name === base.pattern && p.traits.every((t) => base.traits.includes(t)) - ); - const sourcePattern = matchedScored?.traitMap['source_pattern'] ?? base.pattern; + // source_pattern is the fine-grained key (e.g. 'stripe-webhook') codegen/knowledge + // lookups need — distinct from the coarse `pattern` (PatternName) bucket. + const sourcePattern = base.traitMap['source_pattern'] ?? base.pattern; const { stack, entities } = extractEnrichedPrd(intention); const enriched = injectPrdSections(intention, stack, entities); diff --git a/packages/scaffold-core/src/codegen/files.ts b/packages/scaffold-core/src/codegen/files.ts index ea74a36..470d7e0 100644 --- a/packages/scaffold-core/src/codegen/files.ts +++ b/packages/scaffold-core/src/codegen/files.ts @@ -126,7 +126,7 @@ const SCHEMA_BY_PATTERN: Record = { // ─── Index file generator ───────────────────────────────────────────────────── -function generateIndexFile(routes: string[], pattern: string): string { +function generateIndexFile(routes: string[], pattern: string, projectName: string): string { const routeImports = routes .map((route) => { const moduleName = routeToFile(route).replace('src/', './').replace(/\.ts$/, ''); @@ -144,6 +144,10 @@ function generateIndexFile(routes: string[], pattern: string): string { '', 'const app = new Hono<{ Bindings: Env }>();', '', + `app.get("/", (c) => c.json({ service: "${projectName}", pattern: "${pattern}" }));`, + '', + '// TODO: replace/extend with your primary intent route(s) — see registered routes below.', + '', registrations, '', 'app.onError((err, c) => {', @@ -340,16 +344,11 @@ export function baseFiles(facts: ScaffoldFacts): ScaffoldFile[] { const qp = facts.qualityProfile; const intention = facts.intention; - // Extract traits map - const traitsMap: Record = {}; - for (const t of facts.traits) { - const idx = t.indexOf(':'); - if (idx > 0) { - traitsMap[t.slice(0, idx).trim()] = t.slice(idx + 1).trim(); - } - } + // Fine-grained source pattern (e.g. 'stripe-webhook', 'cron-worker') — distinct from + // the coarse `pattern` (PatternName) above, which several source patterns share. + const sourcePattern = facts.sourcePattern ?? pattern; - const routes = (traitsMap['default_routes'] ?? '') + const routes = (facts.traitMap?.['default_routes'] ?? '') .split(',') .map((r) => r.trim()) .filter(Boolean); @@ -362,9 +361,9 @@ export function baseFiles(facts: ScaffoldFacts): ScaffoldFile[] { const hasAi = bindingTypes.includes('ai'); const profile = { - needsTenancy: pattern === 'workers-saas' || qp.piiHandling, - needsPayments: pattern === 'stripe-webhook', - needsStreaming: pattern === 'ai-chat', + needsTenancy: sourcePattern === 'workers-saas' || qp.piiHandling, + needsPayments: sourcePattern === 'stripe-webhook', + needsStreaming: sourcePattern === 'ai-chat', needsStorage: hasR2, needsJwt: qp.authentication && intention.toLowerCase().includes('jwt'), needsOAuth: intention.toLowerCase().includes('oauth'), @@ -376,11 +375,11 @@ export function baseFiles(facts: ScaffoldFacts): ScaffoldFile[] { path: 'wrangler.toml', role: 'config', content: [ - 'name = "stackbilder-generated"', + `name = "${facts.projectName}"`, 'main = "src/worker.ts"', 'compatibility_date = "2026-05-24"', '', - generateWranglerBindings(bindings, pattern), + generateWranglerBindings(bindings, sourcePattern), ].join('\n'), }, { @@ -388,7 +387,7 @@ export function baseFiles(facts: ScaffoldFacts): ScaffoldFile[] { role: 'config', content: JSON.stringify( { - name: 'stackbilder-scaffold', + name: facts.projectName, private: true, type: 'module', scripts: { @@ -399,7 +398,7 @@ export function baseFiles(facts: ScaffoldFacts): ScaffoldFile[] { }, dependencies: { hono: '^4.9.0', - ...(pattern === 'mcp-server' ? { '@modelcontextprotocol/sdk': '^1.0.0' } : {}), + ...(sourcePattern === 'mcp-server' ? { '@modelcontextprotocol/sdk': '^1.0.0' } : {}), }, devDependencies: { '@cloudflare/workers-types': '^4.20260405.1', @@ -473,7 +472,7 @@ export function baseFiles(facts: ScaffoldFacts): ScaffoldFile[] { { path: 'src/worker.ts', role: 'entry', - content: generateIndexFile(routes, pattern), + content: generateIndexFile(routes, sourcePattern, facts.projectName), }, { path: 'src/types/env.ts', @@ -487,7 +486,7 @@ export function baseFiles(facts: ScaffoldFacts): ScaffoldFile[] { ...(hasAi ? [' AI: Ai;'] : []), ' AUTH_BEARER_TOKEN?: string;', ' STRIPE_WEBHOOK_SECRET?: string;', - ...(pattern === 'generic-webhook' + ...(sourcePattern === 'generic-webhook' ? [' WEBHOOK_SECRET?: string;', ' WEBHOOK_SIGNATURE_HEADER?: string;'] : []), ...(profile.needsJwt ? [' JWT_SECRET?: string;'] : []), @@ -589,7 +588,7 @@ export function baseFiles(facts: ScaffoldFacts): ScaffoldFile[] { // D1 schema and migration if (hasD1) { - const schema = SCHEMA_BY_PATTERN[pattern] ?? SCHEMA_BY_PATTERN['rest-api']!; + const schema = SCHEMA_BY_PATTERN[sourcePattern] ?? SCHEMA_BY_PATTERN['rest-api']!; files.push({ path: 'src/db/schema.sql', role: 'migration', content: schema + '\n' }); files.push({ path: 'migrations/0001_initial.sql', role: 'migration', content: schema + '\n' }); } @@ -613,7 +612,7 @@ export function baseFiles(facts: ScaffoldFacts): ScaffoldFile[] { } // Durable Object stub - if (pattern === 'durable-objects' || hasDo) { + if (sourcePattern === 'durable-objects' || hasDo) { files.push({ path: 'src/objects/room.ts', role: 'entry', @@ -724,7 +723,7 @@ export function baseFiles(facts: ScaffoldFacts): ScaffoldFile[] { } // Hardening overlay - if (pattern === 'hardening-overlay') { + if (sourcePattern === 'hardening-overlay') { files.push({ path: '.ai/hardening-checklist.md', role: 'adf', @@ -780,7 +779,9 @@ export function addGovernanceFiles( ...files, { path: '.ai/threat-model.md', role: 'adf', content: governance.threatModel }, { path: '.ai/adr-001.md', role: 'adf', content: governance.adr001 }, - { path: '.ai/adr-002.md', role: 'adf', content: governance.adr002 ?? '' }, + // adr-002 covers compliance-domain requirements (PHI/PCI/PII/telephony) — omit the + // file entirely when there are none rather than shipping a 0-byte artifact. + ...(governance.adr002 ? [{ path: '.ai/adr-002.md', role: 'adf' as const, content: governance.adr002 }] : []), { path: '.ai/test-plan.md', role: 'test', content: governance.testPlan }, { path: '.ai/constraints.yaml', diff --git a/packages/scaffold-core/src/codegen/routes.ts b/packages/scaffold-core/src/codegen/routes.ts index dbbf317..b9af14c 100644 --- a/packages/scaffold-core/src/codegen/routes.ts +++ b/packages/scaffold-core/src/codegen/routes.ts @@ -395,25 +395,16 @@ export function routeContent( * Build the list of route ScaffoldFiles for the given facts. */ export function buildRoutes(facts: ScaffoldFacts): ScaffoldFile[] { - const pattern = facts.pattern as string; + // Fine-grained source pattern (e.g. 'stripe-webhook') — distinct from the coarse + // `pattern` (PatternName), which several source patterns share. + const sourcePattern = facts.sourcePattern ?? (facts.pattern as string); const qp = facts.qualityProfile; const profile = { - needsPayments: qp.authentication && (pattern === 'stripe-webhook' || pattern === 'generic-webhook'), + needsPayments: qp.authentication && (sourcePattern === 'stripe-webhook' || sourcePattern === 'generic-webhook'), needsStreaming: qp.observability, }; - // Extract default_routes from traits - const traitsMap: Record = {}; - for (const t of facts.traits) { - const idx = t.indexOf(':'); - if (idx > 0) { - const k = t.slice(0, idx).trim(); - const v = t.slice(idx + 1).trim(); - traitsMap[k] = v; - } - } - - const defaultRoutes = (traitsMap['default_routes'] ?? '') + const defaultRoutes = (facts.traitMap?.['default_routes'] ?? '') .split(',') .map((r) => r.trim()) .filter(Boolean); @@ -422,7 +413,7 @@ export function buildRoutes(facts: ScaffoldFacts): ScaffoldFile[] { return defaultRoutes.map((route) => ({ path: routeToFile(route), - content: routeContent(route, pattern, profile), + content: routeContent(route, sourcePattern, profile), role: 'entry' as const, })); } diff --git a/packages/scaffold-core/src/governance/adr.ts b/packages/scaffold-core/src/governance/adr.ts index 0609cb3..a6e2625 100644 --- a/packages/scaffold-core/src/governance/adr.ts +++ b/packages/scaffold-core/src/governance/adr.ts @@ -5,13 +5,14 @@ import type { ScaffoldFacts, PatternKnowledge } from '../types'; export function buildAdr001(facts: ScaffoldFacts, knowledge: PatternKnowledge): string { + const sourcePattern = facts.sourcePattern ?? facts.pattern; const context = knowledge.adrContext || `Building ${facts.intention} on Cloudflare Workers.`; const decision = knowledge.adrDecision || - `Use the \`${facts.pattern}\` scaffold pattern as the implementation baseline.`; + `Use the \`${sourcePattern}\` scaffold pattern as the implementation baseline.`; const consequences: string[] = [ - `- Inherits standard ${facts.pattern} file layout and binding conventions`, + `- Inherits standard ${sourcePattern} file layout and binding conventions`, facts.qualityProfile.authentication ? '- Authentication layer is required on all protected routes' : '', facts.qualityProfile.rateLimiting ? '- Rate limiting must be applied at the edge' : '', facts.qualityProfile.observability ? '- Structured logging and trace IDs are required' : '', diff --git a/packages/scaffold-core/src/governance/test-plan.ts b/packages/scaffold-core/src/governance/test-plan.ts index 08b8ec9..63326cc 100644 --- a/packages/scaffold-core/src/governance/test-plan.ts +++ b/packages/scaffold-core/src/governance/test-plan.ts @@ -72,7 +72,7 @@ export function buildTestPlan(facts: ScaffoldFacts): string { `# Test Plan — ${facts.projectName}`, '', `**Testing level**: ${level} `, - `**Pattern**: \`${facts.pattern}\``, + `**Pattern**: \`${facts.sourcePattern ?? facts.pattern}\``, '', '## Required Coverage', '', diff --git a/packages/scaffold-core/src/governance/threat-model.ts b/packages/scaffold-core/src/governance/threat-model.ts index 3c4f1a7..b9ac918 100644 --- a/packages/scaffold-core/src/governance/threat-model.ts +++ b/packages/scaffold-core/src/governance/threat-model.ts @@ -36,7 +36,7 @@ export function buildThreatModel(facts: ScaffoldFacts, knowledge: PatternKnowled '', '## Overview', '', - `**Pattern**: \`${facts.pattern}\` `, + `**Pattern**: \`${facts.sourcePattern ?? facts.pattern}\` `, `**Intent**: ${facts.intention} `, complianceDomains.length > 0 ? `**Compliance domains**: ${complianceDomains.join(', ')}` diff --git a/packages/scaffold-core/src/index.ts b/packages/scaffold-core/src/index.ts index abed285..f18380f 100644 --- a/packages/scaffold-core/src/index.ts +++ b/packages/scaffold-core/src/index.ts @@ -60,6 +60,32 @@ import { generateFiles, addGovernanceFiles } from './codegen/index'; import { materializeScaffold } from './materializer/index'; import { inferBindings } from './classify/bindings'; +const MAX_SLUG_LENGTH = 40; +const FALLBACK_SLUG = 'scaffold-app'; +// Bound the raw input before any regex runs on it — the intention string is public +// hero-demo input, and CodeQL flags the trim regexes below as worst-case-quadratic +// on unbounded input. Slugs are capped at MAX_SLUG_LENGTH anyway, so nothing past a +// generous prefix can ever affect the result; truncating first makes every regex +// below run over a small, fixed-size string regardless of what's typed. +const MAX_RAW_INPUT_LENGTH = 500; + +/** + * Derive a kebab-case project slug from a free-form intention string. + * Used as the default projectName when the caller does not supply one — + * keeps wrangler.toml, package.json, contract filenames, and .ai/*.adf + * headers consistent instead of falling back to a generic placeholder. + */ +export function deriveProjectSlug(intention: string): string { + const bounded = intention.slice(0, MAX_RAW_INPUT_LENGTH); + const slug = bounded + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, MAX_SLUG_LENGTH) + .replace(/-+$/g, ''); + return slug || FALLBACK_SLUG; +} + /** * Build a complete scaffold result from a plain-English intention string. * @@ -80,15 +106,30 @@ export function buildScaffold( options: ScaffoldOptions = {} ): LocalScaffoldResult { const classification = classify(intention); + // source_pattern is the fine-grained key (e.g. 'stripe-webhook', 'cron-worker') that + // knowledge/codegen lookups are keyed by — the coarse `classification.pattern` bucket + // (e.g. 'worker') collapses multiple source patterns together and must not be used here. + const sourcePattern = classification.traitMap['source_pattern'] ?? classification.pattern; const knowledge = getKnowledge( - classification.pattern, + sourcePattern, classification.qualityProfile.complianceDomains ); + // NOTE: inferBindings's first param is documented as the intention text, but this + // call passes the coarse pattern name instead — the "no signal detected" fallback + // (D1+KV) masks it today because route codegen for D1-backed patterns hard-codes + // `c.env.DB` regardless of what inferBindings actually returns. Passing the real + // intention text here changes which bindings get inferred without changing what + // routes assume exists, producing scaffolds whose routes reference undeclared env + // bindings (e.g. `api`/file-upload intentions get R2-only bindings but resources.ts + // still calls `c.env.DB`). Left as-is; fixing it requires reconciling binding + // inference with each pattern's route-level DB/KV/R2 assumptions — out of scope here. const bindings = inferBindings(classification.pattern, classification.traits); const facts = { pattern: classification.pattern, - projectName: options.projectName ?? 'my-worker', + sourcePattern, + traitMap: classification.traitMap, + projectName: options.projectName ?? deriveProjectSlug(intention), intention: classification.enrichedIntention, bindings, traits: classification.traits, @@ -114,6 +155,28 @@ export function buildScaffold( finalFiles = [...finalFiles, mf]; } } + + // Contract stubs import `zod` and `@stackbilt/contracts` directly, but the base + // package.json from codegen/files.ts (generated before the contract file exists) + // only declares `hono` — so a downloaded scaffold survives `npm install` and then + // fails typecheck/build with missing modules. Backfill both dependencies whenever + // a contract file is grafted in. + if (finalFiles.some((f) => f.path.startsWith('src/contracts/'))) { + finalFiles = finalFiles.map((f) => { + if (f.path !== 'package.json') return f; + try { + const pkg = JSON.parse(f.content) as { dependencies?: Record }; + pkg.dependencies = { + ...pkg.dependencies, + zod: '^4.3.6', + '@stackbilt/contracts': '^0.8.0', + }; + return { ...f, content: JSON.stringify(pkg, null, 2) }; + } catch { + return f; + } + }); + } } catch { // Materializer failure is non-fatal — codegen output is still complete } diff --git a/packages/scaffold-core/src/materializer/adf.ts b/packages/scaffold-core/src/materializer/adf.ts index a226090..22a5478 100644 --- a/packages/scaffold-core/src/materializer/adf.ts +++ b/packages/scaffold-core/src/materializer/adf.ts @@ -39,6 +39,13 @@ export function renderManifestAdf(facts: Facts, projectName: string): string { : str(facts, 'elemental_balance', 'unknown'); const shadowDensity = facts.shadow_density ?? 'unknown'; + // Only prepend a card-level keyword when one is actually known — otherwise the row + // would render with a stray leading ", " before the static keywords. + const triggerRow = (name: string, staticKeywords: string): string => { + const prefix = name ? `${name}, ` : ''; + return `${prefix}${staticKeywords}`; + }; + return `# ${projectName} — ADF Manifest # Generated by Stackbilt scaffold engine # Confidence: ${confidence} | Shadow density: ${shadowDensity} @@ -55,12 +62,12 @@ project: "${projectName}" | Domain | Trigger Keywords | |------------|--------------------------------------| -| product | ${str(facts, 'requirement_name')}, requirements, features | -| ux | ${str(facts, 'interface_name')}, layout, components | -| security | ${str(facts, 'threat_name')}, threat, mitigation | -| runtime | ${str(facts, 'runtime_name')}, deploy, worker | -| testing | ${str(facts, 'test_plan_name')}, test, coverage | -| sprint | ${str(facts, 'first_task_name')}, task, estimate | +| product | ${triggerRow(str(facts, 'requirement_name'), 'requirements, features')} | +| ux | ${triggerRow(str(facts, 'interface_name'), 'layout, components')} | +| security | ${triggerRow(str(facts, 'threat_name'), 'threat, mitigation')} | +| runtime | ${triggerRow(str(facts, 'runtime_name'), 'deploy, worker')} | +| testing | ${triggerRow(str(facts, 'test_plan_name'), 'test, coverage')} | +| sprint | ${triggerRow(str(facts, 'first_task_name'), 'task, estimate')} | ## Metrics @@ -89,29 +96,34 @@ export function renderCoreAdf(facts: Facts, projectName: string): string { const threatImpact = str(facts, 'threat_impact'); const threatMitigation = str(facts, 'threat_mitigation'); + // Card-level detail (requirement/interface/threat name + fields) only exists when + // an upstream scaffold-cast pass supplies rawFacts; the local scaffold-core flow + // does not. Render a section only when it has a real name — an empty "### " heading + // with blank fields is worse than no section at all. + const sections: string[] = []; + + sections.push( + reqName + ? `## Product Requirements\n\n### ${reqName}\n- **Priority**: ${reqPriority}\n- **Effort**: ${reqEffort}\n- **Acceptance criteria**: ${reqAcceptance}` + : '## Product Requirements\n\n_No product requirement detail available for this scaffold._', + ); + + sections.push( + ifaceName + ? `## UX Pattern\n\n### ${ifaceName}\n- **Regions**: ${ifaceRegions}\n- **Grid**: ${ifaceGrid}\n- **Components**: ${ifaceComponents}` + : '## UX Pattern\n\n_No UX pattern detail available for this scaffold._', + ); + + sections.push( + threatName + ? `## Security\n\n### ${threatName}\n- **Likelihood**: ${threatLikelihood}\n- **Impact**: ${threatImpact}\n- **Mitigation**: ${threatMitigation}` + : '## Security\n\n_No card-level security detail available here — see `.ai/threat-model.md` for structured threat coverage._', + ); + return `# ${projectName} — Core # Product requirements, UX patterns, and security constraints -## Product Requirements - -### ${reqName} -- **Priority**: ${reqPriority} -- **Effort**: ${reqEffort} -- **Acceptance criteria**: ${reqAcceptance} - -## UX Pattern - -### ${ifaceName} -- **Regions**: ${ifaceRegions} -- **Grid**: ${ifaceGrid} -- **Components**: ${ifaceComponents} - -## Security - -### ${threatName} -- **Likelihood**: ${threatLikelihood} -- **Impact**: ${threatImpact} -- **Mitigation**: ${threatMitigation} +${sections.join('\n\n')} `; } @@ -121,22 +133,23 @@ export function renderStateAdf(facts: Facts, projectName: string): string { const taskComplexity = str(facts, 'first_task_complexity'); const taskDeliverable = str(facts, 'first_task_deliverable'); + // See renderCoreAdf — card-level sprint detail only exists with upstream rawFacts. + const sprintSection = taskName + ? `### ${taskName}\n- **Estimate**: ${taskEstimate} points\n- **Complexity**: ${taskComplexity}\n- **Deliverable**: ${taskDeliverable}\n- **Status**: not_started` + : '_No sprint backlog seeded yet — add tasks here as work begins._'; + return `# ${projectName} — State # Current sprint backlog ## Current Sprint -### ${taskName} -- **Estimate**: ${taskEstimate} points -- **Complexity**: ${taskComplexity} -- **Deliverable**: ${taskDeliverable} -- **Status**: not_started +${sprintSection} ## Velocity | Sprint | Points Planned | Points Done | |--------|---------------|-------------| -| 1 | ${taskEstimate} | — | +| 1 | ${taskEstimate || '—'} | — | `; } diff --git a/packages/scaffold-core/src/materializer/project.ts b/packages/scaffold-core/src/materializer/project.ts index 9415e2f..ed29333 100644 --- a/packages/scaffold-core/src/materializer/project.ts +++ b/packages/scaffold-core/src/materializer/project.ts @@ -364,9 +364,10 @@ function renderContractTs(facts: Facts, projectName: string, prd: PrdSections): }, },` : ''; - const surfaces = (apiRoutes || dbSurface) - ? `\n\n surfaces: {${apiRoutes}${dbSurface}\n },` - : ''; + // @stackbilt/contracts' ContractDefinition.surfaces is a required field (its two + // sub-fields, api/db, are each optional) — always emit it, even empty, or + // defineContract()'s generated call fails to typecheck against the real package. + const surfaces = `\n\n surfaces: {${apiRoutes}${dbSurface}\n },`; const description = str(facts, 'requirement_name') || projectName; @@ -430,7 +431,7 @@ const FIRST_PARTY_DEPS: FirstPartyDep[] = [ }, { pkg: '@stackbilt/contracts', - version: '^0.2.1', + version: '^0.8.0', depType: 'devDep', deps: { zod: '^4.3.6' }, trigger: () => true, @@ -441,14 +442,13 @@ const FIRST_PARTY_DEPS: FirstPartyDep[] = [ }], }, { - // Observability: always inject for Workers + // Observability: disabled — @stackbilt/worker-observability 404s on the npm + // registry (unpublished, same defect class as the tarotscript twin). Re-enable + // the trigger below once the package ships. pkg: '@stackbilt/worker-observability', version: '^0.3.0', depType: 'dep', - trigger: (facts, intention) => - traitsInclude(facts, 'edge', 'v8-isolate', 'serverless', 'isolat') || - str(facts, 'runtime_name').toLowerCase().includes('worker') || - /\b(worker|workers|cloudflare|wrangler)\b/i.test(intention), + trigger: () => false, }, { // Audit chain: tamper-evident audit trail diff --git a/packages/scaffold-core/src/types.ts b/packages/scaffold-core/src/types.ts index 5a1a0c4..af2f76d 100644 --- a/packages/scaffold-core/src/types.ts +++ b/packages/scaffold-core/src/types.ts @@ -43,6 +43,14 @@ export interface ClassifyResult { traits: string[]; qualityProfile: QualityProfile; enrichedIntention: string; + /** + * Key/value trait map from the matched ScoredPatternDef (route_shape, verification, + * dispatch, trigger, framework, default_routes, source_pattern, pattern_element, + * pattern_category, pattern_tier). `source_pattern` is the fine-grained pattern key + * (e.g. 'stripe-webhook') that codegen/governance/knowledge lookups key off of — + * distinct from the coarse `pattern` (PatternName) field above. + */ + traitMap: Record; } export interface QualityProfile { @@ -117,6 +125,10 @@ export interface ScaffoldFacts { bindings: ScaffoldBinding[]; traits: string[]; qualityProfile: QualityProfile; + /** Fine-grained source pattern key (e.g. 'stripe-webhook') — see ClassifyResult.traitMap. */ + sourcePattern: string; + /** Raw trait map carried from classification — used by codegen for default_routes lookup. */ + traitMap: Record; } export interface MaterializerResult {