Skip to content
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ packages/core/scripts/gen-plan.ts
# harness-generated acceptance specs + playwright artifacts (never commit)
apps/*/e2e/_acceptance/
test-results/

# local build/panel logs (never commit)
/scratchpad-*.log*
92 changes: 83 additions & 9 deletions packages/core/src/loop/boringstack/acceptance/e2e-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,78 @@ function renderFieldValue(field: { type: string; valid: string }): string {
return JSON.stringify(field.valid);
}

/**
* Render an invalid override value (for negative tests) to a code literal string.
* Type-aware rendering: tests the constraint with bare types (not strings).
* Rules:
* - empty string "" → stays as "" (tests required-empty constraint)
* - numeric field + token round-trips exactly (`String(Number(value)) === value`) →
* bare number (tests numeric constraint); NON-canonical tokens ("01", "1e5", "0x10",
* " 5 ", huge→Infinity) → JSON string (Number() would mutate them / bare "01" is an
* octal SyntaxError in strict mode)
* - boolean field + value is EXACTLY "true"/"false" → bare boolean (no trim/case-fold)
* - boolean field + other token (incl. " false ", "FALSE") → JSON string (type-rejection)
* - everything else → JSON string (default fallback)
* Complexity: ≤ 20 (three type checks with normalized matching).
*/
function renderInvalidOverride(field: { type: string }, value: string): string {
// Empty string: stay as empty string literal to test required-empty constraint
if (value === "") {
return '""';
}

const normalized = field.type.toLowerCase().trim();

// Numeric types: bare number for finite values (tests numeric constraint)
const numericTypes = new Set([
"number",
"integer",
"int",
"float",
"double",
"decimal",
"numeric",
]);

if (numericTypes.has(normalized)) {
// Emit a bare number ONLY when the token ROUND-TRIPS exactly through Number:
// `String(Number(value)) === value`. This is the true canonical-literal test —
// it accepts "-1"/"1.5" (constraint probes) but rejects tokens Number() would
// MUTATE or that are invalid JS: "01"/"00" (would be an octal SyntaxError in the
// strict-mode spec, and Number("01")→1), "1e5"→100000, "0x10"→16, " 5 "→5, and
// huge digit strings → Infinity. Those fall through to a raw string, which
// exercises type-rejection instead of silently testing a different value.
const asNumber = Number(value);

if (Number.isFinite(asNumber) && String(asNumber) === value) {
return value;
}

return JSON.stringify(value);
}

// Boolean types: bare boolean ONLY for the exact tokens "true"/"false". Any other
// token — including " false " or "FALSE" — is sent as a raw string so we test the
// EXACT value the plan specified (type/value rejection), never a folded valid bool.
const booleanTypes = new Set(["boolean", "bool"]);

if (booleanTypes.has(normalized)) {
if (value === "true") {
return "true";
}

if (value === "false") {
return "false";
}

// Non-boolean token (e.g., "notabool") for a boolean field: JSON string to test type-rejection
return JSON.stringify(value);
}

// Everything else: JSON string
return JSON.stringify(value);
}

/**
* Generate field fill steps for form inputs.
* FIX D: Type-aware field fills (select/checkbox/date/number) instead of blanket .fill().
Expand Down Expand Up @@ -392,13 +464,13 @@ function generateRowCellAssertions(
/**
* Generate negative test blocks.
* B2: Ensures test title and assertions use JSON.stringify for injection-safe escaping.
* B3: Overrides the invalid field using renderFieldValue (type-aware), except
* B3: Overrides the invalid field using renderInvalidOverride (type-aware), except
* required-empty "" stays as empty string to test missing-required constraint.
*
* Negatives work by:
* 1. Authenticate and seed parent entities via API
* 2. Build a payload with all required non-FK fields (valid values) + FK fields (seeded ids)
* 3. Override the target field with the invalid value (type-rendered, or "" for required-empty)
* 3. Override the target field with the invalid value (type-rendered via renderInvalidOverride)
* 4. POST directly to /api/v1/<entity> and assert a 400 or 422 response (validation error codes)
*
* This deterministic API-level check proves validation is enforced without depending on
Expand Down Expand Up @@ -432,13 +504,15 @@ function generateNegativeBlocks(
].filter((s) => s.length > 0);
const payloadFields = allAssignments.join(",\n");

// B3: Render the override value type-aware, except required-empty "" stays as ""
const targetFieldType =
entity.fields.find((f) => f.name === neg.field)?.type ?? "string";
const overrideValue =
neg.value === ""
? '""'
: renderFieldValue({ type: targetFieldType, valid: neg.value });
// B3: Render the override value type-aware via renderInvalidOverride to test the constraint
// with bare types (not strings). This ensures invalid numeric values like "-1" render as
// bare -1 (testing the constraint), and invalid booleans like "notabool" render as the
// string (testing type-rejection).
const fieldDef = entity.fields.find((f) => f.name === neg.field);
const overrideValue = renderInvalidOverride(
fieldDef ?? { type: "string" },
neg.value
);

// B2: Build error message safely without backtick interpolation of plan data
const errorMsg = `expected ${entity.key} to reject ${neg.field} with a validation error (400/422)`;
Expand Down
70 changes: 22 additions & 48 deletions packages/core/src/loop/boringstack/acceptance/e2e-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,44 +144,6 @@ function isInfraError(text: string): boolean {
return infraIndicators.some((indicator) => text.includes(indicator));
}

/**
* Extract error messages from Playwright report JSON stdout.
* Parses the JSON and collects top-level report.errors[].message (not per-test errors).
* Per-test assertion errors must NOT drive suite-wide infra classification.
* Returns empty string if no errors found or JSON is invalid.
*/
function extractReportErrorText(stdout: string): string {
const errors: string[] = [];

if (stdout.trim().length === 0) {
return "";
}

let parsed: unknown;

try {
parsed = JSON.parse(stdout);
} catch {
return "";
}

if (!isPlaywrightReport(parsed)) {
return "";
}

// Collect top-level report errors ONLY
// Global setup/launch failures land here; per-test assertion errors do not.
if (Array.isArray(parsed.errors)) {
for (const err of parsed.errors) {
if ("message" in err && typeof err.message === "string") {
errors.push(err.message);
}
}
}

return errors.join("\n");
}

/**
* Extract the error message from a test result, if present.
*/
Expand Down Expand Up @@ -327,8 +289,14 @@ function classifyNonzeroExit(
shouldRetry: boolean;
} {
// Combine stderr with extracted report errors to form the complete error text
// Extract top-level report errors from the already-parsed report (no re-parse)
const reportErrorText =
parsedReport !== null ? extractReportErrorText(result.stdout) : "";
parsedReport !== null && Array.isArray(parsedReport.errors)
? parsedReport.errors
.map((err) => (typeof err.message === "string" ? err.message : ""))
.filter((msg) => msg.length > 0)
.join("\n")
: "";

const combinedErrorText =
result.stderr + (reportErrorText !== "" ? "\n" + reportErrorText : "");
Expand Down Expand Up @@ -385,7 +353,10 @@ function classifyNonzeroExit(
/**
* Process a single exec result for entity acceptance testing.
* Parses Playwright JSON and extracts results.
* Returns: { outcome, shouldRetry } where outcome is the final result or undefined to continue/retry.
* Returns: { outcome, shouldRetry, parseResult, parsed } where outcome is the final result or undefined to continue/retry.
* parsed is non-null only when JSON was successfully parsed (even if no specs matched).
* This distinction (null vs []) lets the runner preserve stale results from an earlier
* successful parse when a later attempt has unparseable JSON.
*/
export function processExecResult(
result: { code: number; stdout: string; stderr: string },
Expand All @@ -395,6 +366,7 @@ export function processExecResult(
outcome?: IAcceptanceOutcome;
shouldRetry: boolean;
parseResult: IAcceptanceResult[];
parsed: IPlaywrightReportType | null;
} {
// Parse JSON and extract acceptance results
const { parsed: parsedReport, results: parseResult } = parsePlaywrightResults(
Expand All @@ -407,13 +379,15 @@ export function processExecResult(
outcome: summarize(parseResult, requiredSteps),
shouldRetry: false,
parseResult,
parsed: parsedReport,
};
}

// Nonzero exit code: classify as infra or real failure
return {
...classifyNonzeroExit(result, parsedReport, parseResult),
parseResult,
parsed: parsedReport,
};
}

Expand Down Expand Up @@ -557,20 +531,20 @@ export function makeBoringstackAcceptanceRunner(exec: Exec): IAcceptanceRunner {
}
);

const { outcome, shouldRetry, parseResult } = processExecResult(
result,
entity,
requiredSteps
);
const { outcome, shouldRetry, parseResult, parsed } =
processExecResult(result, entity, requiredSteps);

if (outcome !== undefined) {
return outcome;
}

// Preserve parsed results even if classified as infra (for diagnostics).
// Reuse the parse from processExecResult — do not re-parse the same stdout.
// parseResult is always an array (may be empty if JSON was invalid)
lastResults = parseResult;
// Distinguish unparseable (parsed === null) from valid-empty (parsed !== null, parseResult === []):
// - Unparseable (null): keep the previous lastResults from an earlier successful parse
// - Valid-empty (parsed !== null): overwrite lastResults with the latest valid parse (even if empty)
if (parsed !== null) {
lastResults = parseResult;
}

lastError = result.stderr;

Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/loop/boringstack/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@ export const APP_SCHEMA_FILE =
* instructed (refinePrompt) to only ADD its feature's keys, never touch others'. */
export const LOCALE_GLOB = "apps/ui/src/lib/i18n/locales/**";

/** The shared app sidebar navigation component. Features are unreachable (fail browser
* acceptance tests) if the model doesn't register a NavLink for the feature in the
* sidebar. ADD-ONLY shared file: the worker adds only its own NavLink, accepted like
* APP_SCHEMA_FILE/LOCALE_GLOB (filesystem scope grants write, differential gate + judge
* catch any regression to another feature's nav). It's instructed (refinePrompt) to ADD
* ONLY its feature's link, never modify another feature's entry or remove entries. */
export const APP_SIDEBAR_FILE =
"apps/ui/src/components/core/AppSidebar/AppSidebar.tsx";

/** The shared router configuration file. A feature without a route entry is unreachable
* (knip flags the page as unused). ADD-ONLY shared file: the worker adds only its own
* route, same trade-off as APP_SIDEBAR_FILE. It's instructed (refinePrompt) to ADD ONLY
* its feature's route entry, never modify another feature's route. */
export const APP_ROUTES_FILE = "apps/ui/src/app/router/routes.tsx";

export function scopeFor(name: string): string[] {
const camel = toCamelCase(name);

Expand All @@ -116,6 +131,11 @@ export function scopeFor(name: string): string[] {
// Same story for i18n: any UI string is a locale key, and the keys live in
// shared locale files — the model must be able to add the keys it references.
LOCALE_GLOB,
// The sidebar and router are shared UI files. A feature is unreachable (fails
// browser acceptance) unless the model adds a NavLink to the sidebar and a route
// to the router. Add-only: the model may ADD its feature's entry, never modify others'.
APP_SIDEBAR_FILE,
APP_ROUTES_FILE,
];
}

Expand Down
27 changes: 25 additions & 2 deletions packages/core/src/loop/boringstack/refine-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,27 @@ Go through \`tests/helpers/db\` for db/schema — never import \`drizzle-orm\` o

---

## BoringStack UI component conventions — write it RIGHT the first time

The UI eslint (react-component-architecture) is strict; these rules cause the most churn. Copy the scaffold's gate-green example \`apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.tsx\` — a list with per-row actions, exactly the shape your edit/delete rows need.

**No arrow functions or .bind in JSX props (\`jsx-no-bind\` — the #1 churn source).** \`onClick={() => onEdit(row.id)}\` is REJECTED. For per-row actions add a curried helper in \`${camel}.utils.ts\`:
\`\`\`ts
export const makeIdHandler =
(fn: (id: string) => void) => (id: string) => () => fn(id);
\`\`\`
then in the component body \`const editHandler = makeIdHandler(onEdit);\` (and \`deleteHandler\`), and in JSX \`onClick={editHandler(row.id)}\`. The prop is a call expression, not a literal arrow, so the rule passes. This curried-helper pattern is the scaffold's own gate-green convention (see JoinRequestsPage example). For a fully stable handler, a per-row child component with a useCallback is an alternative, but the call-expression helper is the established pattern here. For a zero-arg handler pass a named function reference (\`onClick={handleSubmit}\`), never an inline arrow.

**Extract computed lists.** Build the rows as a const in the body (\`const renderRows = items.map((row) => (<tr…>…</tr>));\`) or a hook — never inline a complex \`.map()\` with logic in the returned JSX ("Extract this computation into a hook").

**One concern per file.** The \`.tsx\` is presentational only. Data-fetching hooks live in the generated PascalCase \`${feature.id}.queries.ts\` (list/fetch queries) and \`${feature.id}.mutations.ts\` (create/update/delete mutations) — FILL THESE, do NOT create parallel \`${camel}.hooks.ts\` files. Component view-state hooks go in a co-located \`<Component>.hooks.ts\` next to the component. Pure helpers in \`${camel}.utils.ts\` / \`<Component>.utils.ts\`. Types in \`${camel}.types.ts\`. NEVER put JSX in a \`.ts\` file (JSX only compiles in \`.tsx\`; a \`.ts\` with \`<X>\` throws "Parsing error: '>' expected"). Never mix a hook + component + type in one module.

**Every visible string via \`t("features.${camel}.<key>")\`** — no hardcoded JSX text (button labels like "Edit"/"Delete" included).

**Make the feature reachable.** Add a sidebar link to \`apps/ui/src/components/core/AppSidebar/AppSidebar.tsx\` (add your feature's entry to the \`APP_SIDEBAR_NAV_ITEMS\` array, following the existing format) and register its route in \`apps/ui/src/app/router/routes.tsx\` (add a route pointing at your feature's page component). Both files are in your editable scope — ADD ONLY your feature's entries, never modify another feature's link/route (same rule as the shared schema + locale files). A feature missing from the sidebar or router is unreachable and fails browser acceptance.

---

## Domain-Fill Instructions

- **Persist for real**: every domain field must be a real column on the \`${camel}\` table (see Persistence above) that the service inserts/selects/updates via Drizzle. NEVER hold a field only in memory to satisfy a test — that stores nothing and is a failed implementation.
Expand All @@ -192,10 +213,12 @@ Go through \`tests/helpers/db\` for db/schema — never import \`drizzle-orm\` o

## Freeze

⚠️ **FREEZE**: Only the files above for the **${feature.id}** resource are editable — that includes adding your entity's columns to the \`${camel}\` table in the app schema. All other files are locked. Do not modify:
⚠️ **FREEZE**: Only the files above for the **${feature.id}** resource are editable. The shared app schema, locale files, AppSidebar.tsx, and routes.tsx are the explicit ADD-ONLY exceptions: the model may edit them only to add entries for this feature, never to modify other features' entries or remove them. All other files are locked. Do not modify:
- Any table OTHER than \`${camel}\` in the app schema; no migrations
- Any locale namespace OTHER than \`features.${camel}\` in the locale files
- Any sidebar link OTHER than your feature's link; do not modify other features' links
- Any route OTHER than your feature's route; do not modify other features' routes
- Root configuration files
- Routes wiring (already done for this resource)
- Other resources' files

If you need to make a change elsewhere, the build has already locked it. Rebase this feature once it passes the gate.
Expand Down
13 changes: 13 additions & 0 deletions packages/core/tests/boringstack-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,19 @@ describe("scopeFor", () => {
expect(scope).toContain(APP_SCHEMA_FILE);
expect(scope).toContain(LOCALE_GLOB);
});

test("includes shared sidebar and router files (add-only, not edit existing entries)", () => {
const scope = scopeFor("Company");

// The sidebar and router are shared UI files. A feature is unreachable (fails
// browser acceptance tests) if the model doesn't register a NavLink for the feature
// in the sidebar and a route entry in the router. Add-only: the model may ADD its
// feature's entry, never modify another feature's entry or remove entries.
expect(scope).toContain(
"apps/ui/src/components/core/AppSidebar/AppSidebar.tsx"
);
expect(scope).toContain("apps/ui/src/app/router/routes.tsx");
});
});

describe("rescueFileFor", () => {
Expand Down
Loading