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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/scaffold-core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
226 changes: 226 additions & 0 deletions packages/scaffold-core/src/__tests__/output-quality.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<string, string>;
devDependencies?: Record<string, string>;
};
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<string, string> };
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');
}
}
});
});
4 changes: 2 additions & 2 deletions packages/scaffold-core/src/__tests__/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/scaffold-core/src/__tests__/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
1 change: 1 addition & 0 deletions packages/scaffold-core/src/classify/classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 4 additions & 7 deletions packages/scaffold-core/src/classify/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,22 @@ 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
*/
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);
Expand Down
Loading