From 1d0e9f9fd68439df21a6f2ff3f3c6223d4303dec Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 15:26:10 +0200 Subject: [PATCH 01/53] =?UTF-8?q?docs(spec):=20app-own-layout=20design=20?= =?UTF-8?q?=E2=80=94=20make=20the=20demo=20dashboard=20disposable,=20scaff?= =?UTF-8?q?old=20apps=20in=20their=20own=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout-descriptor abstraction parameterizing the 7 hardcoded demo-shell coupling points; reuse the existing AppShell with archetype-driven nav-sets (app area vs Settings/Admin area); planner picks the archetype, harness applies it deterministically, build agent gets no new tools. Submitting to the 4-model panel for review of record before implementation. --- .../specs/2026-07-31-app-own-layout-design.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-31-app-own-layout-design.md diff --git a/docs/superpowers/specs/2026-07-31-app-own-layout-design.md b/docs/superpowers/specs/2026-07-31-app-own-layout-design.md new file mode 100644 index 00000000..e477ef07 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-app-own-layout-design.md @@ -0,0 +1,105 @@ +# Scaffold ANY app in its OWN layout (BoringStack demo dashboard becomes disposable) — Design Spec + +**Status:** approved by the user (2026-07-31). Reviewed by a Claude Plan subagent (adversarial critique, findings folded in) and now submitted to the tsforge 4-model `harness-review` panel for the review of record before implementation. + +## Context + +The tsforge harness can already build arbitrary **domain features** (any entities/relations/CRUD). What it CANNOT do is give the generated app its **own layout** — it force-wraps **every** feature into BoringStack's demo `AppShell` + global `AppSidebar` through **seven** hardcoded coupling points. So whatever the user asks for — a project tracker, a CRM, an inbox, a booking tool, a dashboard, anything — it comes out as links bolted onto the same showcase dashboard. BoringStack's dashboard is a **disposable showcase** (a fresh boot has something to click); it is not the frame every app must live in. + +This is the missing half of "scaffold anything": the domain is already general, but the **layout is hardcoded**. A billion possible apps still resolve to a small set of **layout archetypes** (sidebar app, top-nav app, focused single-column, public/marketing, dashboard). Delivering an archetype-driven layout — plus not forcing the demo shell — is what makes the harness able to scaffold arbitrary apps for real. The `#213` work (sidebar grouping + home redirect) never questioned that the demo `AppShell` is the container, so it was lipstick on the wrong assumption; this spec undoes it. + +**Confirmed with the user:** +- The scaffold's dashboard/account pages are **real capabilities** (Stripe billing, MFA, OAuth, team invites, audit log, notifications), not demo filler. **Keep them all**, relocated into a **Settings/Admin area** reached from the app via the header avatar/gear. Nothing deleted. +- The app the user asked for is **primary**, in its **own** layout, and is where you land. The demo dashboard is only present if the user actually wants a dashboard (an opt-in archetype). +- Deliver the **general** "layout archetype → layout descriptor" mechanism; implement the archetypes incrementally, proving the mechanism on **more than one shape of app**, not a single hardcoded example. + +**Outcome:** the harness scaffolds the requested app in a layout that fits it, the demo dashboard is disposable/opt-in, and adding a new layout archetype is a small, well-bounded change rather than a rewrite. + +--- + +## Architecture — one shell, archetype-driven nav-sets + +Generating a *second* `AppShell` per app invites (a) import-name collisions in `routes.tsx`, and (b) re-providing the React context the account pages rely on (`AppPageHeaderProvider`, `AccountSwitcher`, `useMe`). Both are avoided by **reusing the existing `AppShell`** (keep its header/avatar/providers) and making the **archetype select which nav-set + wrapper the shell renders**: + +- **App area** — the `AppShell` renders the **app nav-set** (the plan's feature slices) as its sidebar; header avatar/gear → Settings. The `home` slice is the post-login landing (reuse `#213` redirect). This is the app's own layout — its own nav, its own landing. +- **Settings/Admin area** — the same `AppShell` rendering the **account nav-set** (existing `/dashboard` + `/account/*` + `/notifications` links) plus a "← back to app" link. Account pages/routes untouched. +- **Nav-set ownership:** app-role features register in an **app nav-set** (scoped to the model); account/settings pages stay in the existing account nav-set (the current `APP_SIDEBAR_NAV_ITEMS`, repurposed). Which sidebar renders is chosen by route-area. +- **Layout descriptor** — one object per archetype parameterizing the seams. `dashboard` archetype = today's single-nav-set behavior (byte-identical, for apps that genuinely want a dashboard). `app-sidebar` = the app/settings split above. `routeWrapper` stays `` for both (no second component, no collision); a future `public`/`focused`/`top-nav` archetype can vary `routeWrapper`, the nav mechanism, or drop `ProtectedRoute` — the abstraction is built to absorb them without touching the seams again. + +### The seven coupling points (verified on `main`) +1. **Route wrapper** — `wire-resource.ts` `wireUiRouteFile()` L157-169 (the `` literal). +2. **Scope** — `build.ts` `scopeFor()` L162-180 + constants L108-143 (`APP_SIDEBAR_FILE`, `APP_ROUTES_FILE`, `APP_SIDEBAR_TEST_FILE`). +3. **Refine prompt** — `refine-prompt.ts` `layoutGuidance()` L8-38 + closing nav instruction ~L306. +4. **Nav-testid contract** — `acceptance/testid-contract.ts` `requiredTestIds`/`buildTestIdGuide`/`checkTestIds`. +5. **E2E nav step** — `acceptance/e2e-generator.ts` `generateEntitySpec()` ~L681-686 (`dashboard.goto()` + click `nav-`). +6. **Fast-gate sidebar test path** — `gate.ts` ~L70 hardcodes `... src/components/core/AppSidebar`. The app nav-set's test MUST be in this path or reachability goes unverified until final acceptance = false-green. +7. **Shell provisioning** — the harness assumes `AppShell`/`AppSidebar` exist and only appends; reuse resolves this, the nav-set split is the new wiring. + +**Reuse as-is:** `#213` `homeRouteForPlan`/`wireHomeRedirectForPlan`/`applyHomeRedirect`; `plan-types.ts` `LAYOUT_ARCHETYPES`/`IMPLEMENTED_LAYOUT_ARCHETYPES`/`IUiIntent.layout+home`; `conventions.ts` guides; the existing `AppShell`/`AppSidebar`. + +### How the layout decision is made (no new agent tools) +Two decision points, only one is judgment: +1. **Planner** (`propose-plan.ts`, existing LLM seam) picks the **archetype** per the product description, constrained to `IMPLEMENTED_LAYOUT_ARCHETYPES`, and emits it as `ui.layout`. It surfaces in the plan the **user approves** — the human is the backstop on the choice. +2. **Harness** (deterministic code) resolves `getLayoutDescriptor(layout)` and applies it: route wrapper, nav-set split, Settings area, home redirect, gate test path. No LLM, no guessing. + +The **build agent gets NO new tools and NO layout discretion.** Its only layout-related action is adding its feature's nav link to the exact file the harness scoped + named in the refine-prompt. Rationale (hard lessons this session): every time the model *decided* load-bearing structure it went false-green/park (bolted onto the demo shell; home-redirect + FK-visibility had to be made deterministic). So: **archetype chosen once (planner, user-approved) → structure applied deterministically (harness) → agent fills in domain code only.** + +### `ILayoutDescriptor` +```ts +interface ILayoutDescriptor { + routeWrapper: { open: string; close: string }; // JSX around ; fixed AppShell for app-sidebar & dashboard (no 2nd import/alias/collision) + sharedEditableGlobs: string[]; // which shared files this feature may edit + navRegistration: { promptGuidance: string; navFile: string | null; testId: (camel: string) => string }; + e2eNav: { startArea: "app" | "dashboard"; testId: (camel: string) => string }; + testIdContract: { navLocation: "app-navset" | "account-navset" }; + sidebarTestGlob: string; // path the fast gate must run (seam #6) +} +export function getLayoutDescriptor(layout: LayoutArchetype): ILayoutDescriptor // throws on unknown +``` + +--- + +## Stages (each independently landable + panel-gated) + +### Stage 1 — Descriptor abstraction (foundation, no behavior change) +New `layout-descriptor.ts`: `ILayoutDescriptor`, `getLayoutDescriptor`, descriptors for `app-sidebar` + `dashboard`. Pure, unit-tested; nothing calls it yet. + +### Stage 2 — Thread the descriptor through all seven seams; `dashboard` reproduces today EXACTLY +Refactor each seam to read from the descriptor; call sites resolve `getLayoutDescriptor(slice.ui.layout ?? "app-sidebar")`. `scopeFor()` gains a `layout` param. `gate.ts` reads `sidebarTestGlob`. **Regression gate:** per-seam value-equality tests assert the `dashboard` descriptor's output equals today's hardcoded output — existing dashboard builds can't regress. + +### Stage 3 — `app-sidebar` archetype end-to-end +- **Nav-set split (deterministic, plan-level, idempotent — like `applyHomeRedirect`):** point the `AppShell` sidebar at the app nav-set for app routes and the account nav-set for `/account`+`/dashboard`; add avatar/gear → Settings and a "← back to app" link (→ the `home` route, or app root). Harness-injected, NOT model-authored. Runs after the pristine gate baseline + `captureMetaBaseline` + infra fail-closed, skip-if-present. +- **Routing/scope/prompt:** app features register in the app nav-set; `scopeFor("app-sidebar")` grants the app nav-set + its test, not the account one. `refinePrompt` tells app features → app nav-set, **settings-role features → account nav-set**. +- **Reachability verified (no false-green):** the app nav-set has a nav-count/reachability test in `sidebarTestGlob`, run by the fast gate. Frozen-sibling coupling is the same already-handled pattern (#46/#65/#81), on the app nav-set. +- **E2E:** nav step uses `descriptor.e2eNav` (start in the app area, click the app link) instead of `dashboard.goto()`. + +### Stage 4 — Plan schema + planner +Add `"dashboard"` to `LAYOUT_ARCHETYPES` **and** `IMPLEMENTED_LAYOUT_ARCHETYPES`. Default `IUiIntent.layout` → `app-sidebar`. Planner (`propose-plan.ts`) chooses the archetype from the product description (dashboard only when the user wants a dashboard); `plan-store.ts` already gates on the implemented set. + +### Stage 5 — Tests + live proof of GENERALITY +Unit tests per seam (both descriptors) + regression (dashboard byte-equal) + split-wiring (idempotent, resume no-op, knip import-chain intact). +**Live acceptance across DIFFERENT apps (the point is generality, not any one app):** +- A **single-entity** feature app — lands on its own app area, settings reachable via gear, CRUD green. +- A **multi-slice relational** app (e.g. a small CRM: Company→Contact→Deal) — proves the app nav-set holds several features + relations, still its own layout, not the demo dashboard. +- A **`dashboard`-archetype** app — proves the legacy path is byte-unchanged. +Each: `boringstack done · N/N verified` + final acceptance green, and manual/e2e confirmation of app-area-primary + Settings/Admin intact. + +--- + +## Critical files +- **New:** `layout-descriptor.ts` (+ tests); nav-set-split wiring (new fn mirroring `applyHomeRedirect`). +- **Modify:** `wire-resource.ts` (`wireUiRouteFile`), `build.ts` (`scopeFor` + `runBoringstackBuild` split call), `refine-prompt.ts` (`layoutGuidance` + closing instruction), `gate.ts` (sidebar test path from descriptor), `acceptance/testid-contract.ts`, `acceptance/e2e-generator.ts`, `planning/plan-types.ts` + `planning/propose-plan.ts`. +- **Reuse:** `homeRouteForPlan`/`wireHomeRedirectForPlan`/`applyHomeRedirect`, existing `AppShell`/`AppSidebar`, `conventions.ts` guides. + +## Risks (biggest first) +1. **Fast-gate sidebar-test path (seam #6, false-green risk)** — the app nav-set's test MUST run in `gate.ts`, or reachability is unverified till final acceptance. Fixed via descriptor `sidebarTestGlob` + a test asserting the fast gate runs it. +2. **Backward compat** — `dashboard` descriptor must equal today's output; per-seam value-equality regression tests. +3. **Nav-set ownership boundary** — app features → app nav-set, settings features → account nav-set; the split-wiring routes each area's sidebar. Deterministic + idempotent, plan-level. +4. **Scope enforcement** — building an app feature must deny edits to the account/demo nav-set; scope-violation test. +5. **Resume-safety/ordering** — split + home redirect run plan-level after both pristine captures + infra fail-closed, skip-if-present (the `#213` ordering trap). +6. **Shared React context** — resolved by reusing `AppShell` (keeps `AppPageHeaderProvider`/`AccountSwitcher`/`useMe`); verify feature pages using `useAppPageHeader` still get it (same shell — they do). + +## Verification +- Harness `bun run validate` green; unit + per-seam regression + split-wiring tests pass. +- Panel-gate every stage's PR via the 4-model `harness-review` (reviewers ok ≥ 2), as always. +- Live: the three different apps above each build to `N/N verified` + final acceptance green, land on their own app area (app nav only), and expose the real account pages via the gear-driven Settings/Admin area. Generality is the acceptance bar — a single app passing is necessary, not sufficient. From fbc2217f22441e25cd1b8a5a5c311a28202f4e0a Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 15:36:24 +0200 Subject: [PATCH 02/53] =?UTF-8?q?docs(spec):=20app-own-layout=20v2=20?= =?UTF-8?q?=E2=80=94=20address=20panel=20BLOCK=20(two=20axes,=20opt-in=20d?= =?UTF-8?q?efault,=20deterministic=20route-area,=20landable=20stages)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel r1 BLOCKED v1. v2: split the overloaded ui.layout into plan.shellLayout (dashboard|app-sidebar, absent=today) + slice.navRole (app|settings); planner emits shellLayout so new apps get their own layout while legacy plans stay unchanged; route-area chosen by a wire-time navSet prop (no URL guessing); fold the existing 'settings' archetype into navRole; gate runs the sidebar-test union keyed on shellLayout; e2e gains an app-area landing helper; home defaults to the first app-role slice; nav-set split specified at the code-mutation level; stages re-sequenced to be landable with no dead code and no default-flip. --- .../specs/2026-07-31-app-own-layout-design.md | 141 +++++++++--------- 1 file changed, 73 insertions(+), 68 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-app-own-layout-design.md b/docs/superpowers/specs/2026-07-31-app-own-layout-design.md index e477ef07..026b40ba 100644 --- a/docs/superpowers/specs/2026-07-31-app-own-layout-design.md +++ b/docs/superpowers/specs/2026-07-31-app-own-layout-design.md @@ -1,105 +1,110 @@ -# Scaffold ANY app in its OWN layout (BoringStack demo dashboard becomes disposable) — Design Spec +# Scaffold ANY app in its OWN layout (BoringStack demo dashboard becomes disposable) — Design Spec v2 -**Status:** approved by the user (2026-07-31). Reviewed by a Claude Plan subagent (adversarial critique, findings folded in) and now submitted to the tsforge 4-model `harness-review` panel for the review of record before implementation. +**Status:** approved by the user (2026-07-31). v1 was BLOCKED by the tsforge 4-model panel; v2 addresses every critical/major finding (two-axis model, opt-in default preserving backward-compat, deterministic route-area, existing `settings` archetype folded in, landable re-sequenced stages, gate/e2e/home plumbed against real code). Re-submitted to the panel for the review of record before implementation. ## Context -The tsforge harness can already build arbitrary **domain features** (any entities/relations/CRUD). What it CANNOT do is give the generated app its **own layout** — it force-wraps **every** feature into BoringStack's demo `AppShell` + global `AppSidebar` through **seven** hardcoded coupling points. So whatever the user asks for — a project tracker, a CRM, an inbox, a booking tool, a dashboard, anything — it comes out as links bolted onto the same showcase dashboard. BoringStack's dashboard is a **disposable showcase** (a fresh boot has something to click); it is not the frame every app must live in. +The tsforge harness already builds arbitrary **domain features** (any entities/relations/CRUD). What it CANNOT do is give the generated app its **own layout** — it force-wraps **every** feature into BoringStack's demo `AppShell` + global `AppSidebar` through **seven** hardcoded coupling points. So whatever the user asks for comes out as links bolted onto the same showcase dashboard. BoringStack's dashboard is a **disposable showcase**, not the frame every app must live in. A billion possible apps still resolve to a small set of **layout archetypes**; delivering archetype-driven layout — plus not forcing the demo shell — is what makes "scaffold anything" real. `#213` (sidebar grouping + home redirect) never questioned that the demo `AppShell` is the container; this spec undoes that. -This is the missing half of "scaffold anything": the domain is already general, but the **layout is hardcoded**. A billion possible apps still resolve to a small set of **layout archetypes** (sidebar app, top-nav app, focused single-column, public/marketing, dashboard). Delivering an archetype-driven layout — plus not forcing the demo shell — is what makes the harness able to scaffold arbitrary apps for real. The `#213` work (sidebar grouping + home redirect) never questioned that the demo `AppShell` is the container, so it was lipstick on the wrong assumption; this spec undoes it. +**Confirmed with the user:** the scaffold's dashboard/account pages are **real capabilities** (Stripe billing, MFA, OAuth, team, audit, notifications) — keep them all, relocated into a **Settings/Admin area** reached via the header avatar/gear; nothing deleted. The requested app is **primary**, in its **own** layout, and is where you land. Deliver the **general** mechanism; prove it on **several different apps**, not one example. -**Confirmed with the user:** -- The scaffold's dashboard/account pages are **real capabilities** (Stripe billing, MFA, OAuth, team invites, audit log, notifications), not demo filler. **Keep them all**, relocated into a **Settings/Admin area** reached from the app via the header avatar/gear. Nothing deleted. -- The app the user asked for is **primary**, in its **own** layout, and is where you land. The demo dashboard is only present if the user actually wants a dashboard (an opt-in archetype). -- Deliver the **general** "layout archetype → layout descriptor" mechanism; implement the archetypes incrementally, proving the mechanism on **more than one shape of app**, not a single hardcoded example. +--- + +## Two axes (the v1 critical fix) + +v1 overloaded `IUiIntent.layout`, which on `main` already means **nav placement within the one shell** (`app-sidebar` | `settings`). Shell choice and per-feature nav placement are **different axes**: + +- **`IProductPlan.shellLayout?: ShellLayout`** — PLAN-level. `ShellLayout = "dashboard" | "app-sidebar"`. **Absent → `"dashboard"` = today's EXACT single-shell behavior** (backward-compat; no existing plan changes). `"app-sidebar"` = the app owns its layout (app area + Settings/Admin area split). The **planner emits `shellLayout` for every new plan** (`app-sidebar` for normal apps, `dashboard` only when the product IS a dashboard) — so new apps get their own layout by default, while stored/legacy plans with no field rebuild identically. +- **`IUiIntent.navRole?: "app" | "settings"`** — SLICE-level, default `"app"`. Only meaningful when `shellLayout === "app-sidebar"`: which nav-set the feature's link joins (primary app nav vs the Settings/Admin nav). Under `shellLayout: "dashboard"` it is ignored (one nav-set, today's behavior). +- **Legacy `IUiIntent.layout` migration:** keep the field readable; `parsePlan` maps a legacy `layout: "settings"` → `navRole: "settings"`, `layout: "app-sidebar"` → `navRole: "app"`. `layout` no longer selects a shell. The existing `settings` archetype thus becomes a **navRole**, not a shell — resolving the "`getLayoutDescriptor` throws on settings" critical. `LAYOUT_ARCHETYPES`/`IMPLEMENTED_LAYOUT_ARCHETYPES` are retired/renamed to the two-axis types. -**Outcome:** the harness scaffolds the requested app in a layout that fits it, the demo dashboard is disposable/opt-in, and adding a new layout archetype is a small, well-bounded change rather than a rewrite. +**Descriptor is keyed by the PLAN's `shellLayout`, not per-slice** — resolving the mixed-plan ambiguity (which descriptor drives the shared shell, gate, e2e). Per-slice `navRole` only selects the nav-set within the app-sidebar shell. --- -## Architecture — one shell, archetype-driven nav-sets +## Architecture — reuse the existing AppShell; route-area chosen deterministically at wire-time -Generating a *second* `AppShell` per app invites (a) import-name collisions in `routes.tsx`, and (b) re-providing the React context the account pages rely on (`AppPageHeaderProvider`, `AccountSwitcher`, `useMe`). Both are avoided by **reusing the existing `AppShell`** (keep its header/avatar/providers) and making the **archetype select which nav-set + wrapper the shell renders**: +Generating a second `AppShell` invites import collisions + re-providing the account pages' React context (`AppPageHeaderProvider`, `AccountSwitcher`, `useMe`). Instead **reuse the existing `AppShell`** and make it render one of two nav-sets, selected by an **explicit prop set at wire-time** (NOT runtime URL-prefix guessing — that was a v1 critical: `/notifications` isn't under `/account`, and generated apps can collide with `/dashboard`): -- **App area** — the `AppShell` renders the **app nav-set** (the plan's feature slices) as its sidebar; header avatar/gear → Settings. The `home` slice is the post-login landing (reuse `#213` redirect). This is the app's own layout — its own nav, its own landing. -- **Settings/Admin area** — the same `AppShell` rendering the **account nav-set** (existing `/dashboard` + `/account/*` + `/notifications` links) plus a "← back to app" link. Account pages/routes untouched. -- **Nav-set ownership:** app-role features register in an **app nav-set** (scoped to the model); account/settings pages stay in the existing account nav-set (the current `APP_SIDEBAR_NAV_ITEMS`, repurposed). Which sidebar renders is chosen by route-area. -- **Layout descriptor** — one object per archetype parameterizing the seams. `dashboard` archetype = today's single-nav-set behavior (byte-identical, for apps that genuinely want a dashboard). `app-sidebar` = the app/settings split above. `routeWrapper` stays `` for both (no second component, no collision); a future `public`/`focused`/`top-nav` archetype can vary `routeWrapper`, the nav mechanism, or drop `ProtectedRoute` — the abstraction is built to absorb them without touching the seams again. +- The route wrapper passes `` for app-role feature routes and `` for the scaffold's account/dashboard/notifications routes. `AppShell` renders the app `AppSidebar` variant or the account one from that prop — deterministic, per-route, collision-proof, and covers the mobile `Sheet` the same way. +- **App area:** `AppShell navSet="app"` + the **app nav-set** (feature slices with `navRole:"app"`); header avatar/gear → Settings. Post-login lands on the app (see Home below). +- **Settings/Admin area:** `AppShell navSet="account"` + the **account nav-set** (the current `APP_SIDEBAR_NAV_ITEMS`: dashboard/notifications/team/audit/settings/billing/profile) + a "← back to app" link. Account pages/routes untouched. +- **`shellLayout: "dashboard"`** keeps exactly one nav-set (`navSet="app"` for all, pointing at today's single `APP_SIDEBAR_NAV_ITEMS`) → byte-identical to today. -### The seven coupling points (verified on `main`) -1. **Route wrapper** — `wire-resource.ts` `wireUiRouteFile()` L157-169 (the `` literal). -2. **Scope** — `build.ts` `scopeFor()` L162-180 + constants L108-143 (`APP_SIDEBAR_FILE`, `APP_ROUTES_FILE`, `APP_SIDEBAR_TEST_FILE`). -3. **Refine prompt** — `refine-prompt.ts` `layoutGuidance()` L8-38 + closing nav instruction ~L306. -4. **Nav-testid contract** — `acceptance/testid-contract.ts` `requiredTestIds`/`buildTestIdGuide`/`checkTestIds`. -5. **E2E nav step** — `acceptance/e2e-generator.ts` `generateEntitySpec()` ~L681-686 (`dashboard.goto()` + click `nav-`). -6. **Fast-gate sidebar test path** — `gate.ts` ~L70 hardcodes `... src/components/core/AppSidebar`. The app nav-set's test MUST be in this path or reachability goes unverified until final acceptance = false-green. -7. **Shell provisioning** — the harness assumes `AppShell`/`AppSidebar` exist and only appends; reuse resolves this, the nav-set split is the new wiring. +### The seven coupling points (verified `main`) + the new axis threading +1. **Route wrapper** — `wire-resource.ts` `wireUiRouteFile()` (L133-187): emit `…` where `role` derives from the slice's `navRole` under `app-sidebar`, else today's literal. `wireUiRouteFile` gains a descriptor/navSet arg. +2. **Scope** — `build.ts` `scopeFor()` (L162-180): gains a `(name, shellLayout, navRole)` signature; under `app-sidebar`+`navRole:"app"` grants the **app nav-set file + its test** (not the account sidebar); under `dashboard` returns today's globs exactly. +3. **Refine prompt** — `refine-prompt.ts` `layoutGuidance()`/closing instruction: tell an `app` feature to register in the app nav-set, a `settings` feature in the account nav-set. Descriptor-driven text. +4. **Nav-testid contract** — `acceptance/testid-contract.ts`: `nav-` required on whichever nav-set the feature's `navRole` selects. +5. **E2E** — `acceptance/e2e-generator.ts`: `authedPage.dashboard.goto()` appears at MANY step sites (nav/list/create/update/delete/negatives), not just L681-686. Add an **app-area landing helper** (`authedPage.app.goto()` → the app home route). App-role specs start there; settings-role specs keep `dashboard.goto()`. All goto sites are parameterized by the feature's area. +6. **Fast-gate sidebar test path** — `gate.ts` `runBoringstackGate` builds a single project-wide `FAST_GATE` with hardcoded `src/components/core/AppSidebar`. The gate is project-wide (no slice arg), so it must run the **union**: the account sidebar test ALWAYS + the app nav-set test when `plan.shellLayout==="app-sidebar"`. `shellLayout` reaches the gate at the plan/build level (available in `runBoringstackBuild`), not per-slice. +7. **Shell provisioning** — reuse the existing `AppShell`/`AppSidebar`; the new wiring is the nav-set split + the app nav-set constant + its test (below). -**Reuse as-is:** `#213` `homeRouteForPlan`/`wireHomeRedirectForPlan`/`applyHomeRedirect`; `plan-types.ts` `LAYOUT_ARCHETYPES`/`IMPLEMENTED_LAYOUT_ARCHETYPES`/`IUiIntent.layout+home`; `conventions.ts` guides; the existing `AppShell`/`AppSidebar`. +### Nav-set split — code-level (v1 "not designed at the mutation level" fix) +Deterministic, plan-level, idempotent (marker-guarded, skip-if-present), harness-injected (NOT model-authored), run in `runBoringstackBuild` AFTER the pristine gate baseline + `captureMetaBaseline` + infra fail-closed (the `#213` ordering trap), only when `shellLayout==="app-sidebar"`: +- **`AppSidebar`:** split today's `APP_SIDEBAR_NAV_ITEMS` into `ACCOUNT_NAV_ITEMS` (the existing 7) and a new empty `APP_NAV_ITEMS` (feature links append here). `AppSidebar` takes the `navSet` prop and renders the matching list (desktop + mobile `Sheet` parity). Keep `ACCOUNT_NAV_ITEMS` referenced so knip is satisfied; the empty `APP_NAV_ITEMS` is referenced by the `navSet==="app"` branch (import chain intact). +- **App nav-set test:** ships with an initial assertion (baseline link count = 0/however the scaffold seeds it) at the path `gate.ts` runs (seam #6); each app feature bumps it (same already-handled frozen-sibling pattern #46/#65/#81). +- **AppShell:** avatar/gear → Settings (`/account/profile`); the account variant gets a "← back to app" link → the app home route. +- **Resume mid-migration:** the split is marker-guarded so a resumed build doesn't re-split or clobber model-added `APP_NAV_ITEMS` entries. -### How the layout decision is made (no new agent tools) -Two decision points, only one is judgment: -1. **Planner** (`propose-plan.ts`, existing LLM seam) picks the **archetype** per the product description, constrained to `IMPLEMENTED_LAYOUT_ARCHETYPES`, and emits it as `ui.layout`. It surfaces in the plan the **user approves** — the human is the backstop on the choice. -2. **Harness** (deterministic code) resolves `getLayoutDescriptor(layout)` and applies it: route wrapper, nav-set split, Settings area, home redirect, gate test path. No LLM, no guessing. +### Home (v1 "lands in Settings" fix) +`homeRouteForPlan` today returns null when no slice has `home:true`, leaving `DEFAULT_REDIRECT_TO = /dashboard` (which becomes the Settings area). Under `shellLayout: "app-sidebar"`, if no explicit `home`, **default the redirect to the first `navRole:"app"` slice's route** — never `/dashboard`. So an app-sidebar app always lands in the app area. -The **build agent gets NO new tools and NO layout discretion.** Its only layout-related action is adding its feature's nav link to the exact file the harness scoped + named in the refine-prompt. Rationale (hard lessons this session): every time the model *decided* load-bearing structure it went false-green/park (bolted onto the demo shell; home-redirect + FK-visibility had to be made deterministic). So: **archetype chosen once (planner, user-approved) → structure applied deterministically (harness) → agent fills in domain code only.** +### How the decision is made (no new agent tools) +Planner picks `shellLayout` (+ per-slice `navRole`), user-approves the plan. Harness applies the shell descriptor deterministically. The build agent gets **no new tools and no layout discretion** — it only adds its feature's nav link to the nav-set file the harness scoped + named. (Hard lesson this session: model-decided load-bearing structure → false-green/park.) -### `ILayoutDescriptor` +### `IShellDescriptor` (keyed by shellLayout) ```ts -interface ILayoutDescriptor { - routeWrapper: { open: string; close: string }; // JSX around ; fixed AppShell for app-sidebar & dashboard (no 2nd import/alias/collision) - sharedEditableGlobs: string[]; // which shared files this feature may edit - navRegistration: { promptGuidance: string; navFile: string | null; testId: (camel: string) => string }; - e2eNav: { startArea: "app" | "dashboard"; testId: (camel: string) => string }; - testIdContract: { navLocation: "app-navset" | "account-navset" }; - sidebarTestGlob: string; // path the fast gate must run (seam #6) +type ShellLayout = "dashboard" | "app-sidebar"; +type NavRole = "app" | "settings"; +interface IShellDescriptor { + navSetFor: (role: NavRole) => "app" | "account"; // which AppShell nav-set the route uses + navFileFor: (role: NavRole) => string; // the nav-set file the feature edits + sidebarTestGlobs: string[]; // union the fast gate must run (seam #6) + e2eStartFor: (role: NavRole) => "app" | "dashboard"; // e2e landing helper + splitsNavSets: boolean; // app-sidebar=true, dashboard=false (today) } -export function getLayoutDescriptor(layout: LayoutArchetype): ILayoutDescriptor // throws on unknown +export function getShellDescriptor(shell: ShellLayout): IShellDescriptor // throws on unknown ``` +The `dashboard` descriptor maps every role to today's single nav-set/file/test/e2e (byte-identical). Honest scope note: this descriptor covers `{dashboard, app-sidebar}` only. Future `public`/`focused`/`top-nav` need additional fields (auth boundary, shell presence, nav mechanism, provisioning) — documented as explicit extension points, NOT claimed-covered now (v1 over-claimed generality). --- -## Stages (each independently landable + panel-gated) +## Stages (re-sequenced so each is real, landable, no dead code, no default-flip) -### Stage 1 — Descriptor abstraction (foundation, no behavior change) -New `layout-descriptor.ts`: `ILayoutDescriptor`, `getLayoutDescriptor`, descriptors for `app-sidebar` + `dashboard`. Pure, unit-tested; nothing calls it yet. +### Stage 1 — Two-axis schema + backward-compat (no behavior change) +Add `IProductPlan.shellLayout` + `IUiIntent.navRole`; migrate legacy `layout` (`parsePlan` maps to `navRole`); retire `LAYOUT_ARCHETYPES`/`IMPLEMENTED_LAYOUT_ARCHETYPES` into the two-axis types + `plan-store.ts` validation. **Absent `shellLayout` → "dashboard" (today).** Tests: legacy plans (no field / `layout:"settings"` / `layout:"app-sidebar"`) parse to the correct axes; absent → dashboard. No seam touched yet — pure schema, landable, zero behavior change. -### Stage 2 — Thread the descriptor through all seven seams; `dashboard` reproduces today EXACTLY -Refactor each seam to read from the descriptor; call sites resolve `getLayoutDescriptor(slice.ui.layout ?? "app-sidebar")`. `scopeFor()` gains a `layout` param. `gate.ts` reads `sidebarTestGlob`. **Regression gate:** per-seam value-equality tests assert the `dashboard` descriptor's output equals today's hardcoded output — existing dashboard builds can't regress. +### Stage 2 — `IShellDescriptor` + thread through the seven seams; `dashboard` == today EXACTLY +Add `layout-descriptor.ts` (`getShellDescriptor`, both descriptors) AND wire it through all seams in the SAME stage (no dead-code interim). Every seam resolves `getShellDescriptor(plan.shellLayout ?? "dashboard")`. The `dashboard` path is byte-identical to today (per-seam value-equality regression tests against the current literals). The `app-sidebar` path is new but exercised only when a plan opts in — so landing Stage 2 changes nothing for existing plans (they're dashboard) yet the new path is fully unit-tested. -### Stage 3 — `app-sidebar` archetype end-to-end -- **Nav-set split (deterministic, plan-level, idempotent — like `applyHomeRedirect`):** point the `AppShell` sidebar at the app nav-set for app routes and the account nav-set for `/account`+`/dashboard`; add avatar/gear → Settings and a "← back to app" link (→ the `home` route, or app root). Harness-injected, NOT model-authored. Runs after the pristine gate baseline + `captureMetaBaseline` + infra fail-closed, skip-if-present. -- **Routing/scope/prompt:** app features register in the app nav-set; `scopeFor("app-sidebar")` grants the app nav-set + its test, not the account one. `refinePrompt` tells app features → app nav-set, **settings-role features → account nav-set**. -- **Reachability verified (no false-green):** the app nav-set has a nav-count/reachability test in `sidebarTestGlob`, run by the fast gate. Frozen-sibling coupling is the same already-handled pattern (#46/#65/#81), on the app nav-set. -- **E2E:** nav step uses `descriptor.e2eNav` (start in the app area, click the app link) instead of `dashboard.goto()`. +### Stage 3 — `app-sidebar` end-to-end (behind `shellLayout:"app-sidebar"`) +Nav-set split wiring (code-level above) + app nav-set + its gate-run test + AppShell `navSet` prop + avatar/gear + back-to-app + home default. All gated on `shellLayout==="app-sidebar"`; dashboard untouched. Scope/prompt/testid/e2e/gate all honor `navRole`. Unit + integration tests (split idempotent, resume no-op, knip chain, gate runs the app nav-set test, e2e app-landing helper). -### Stage 4 — Plan schema + planner -Add `"dashboard"` to `LAYOUT_ARCHETYPES` **and** `IMPLEMENTED_LAYOUT_ARCHETYPES`. Default `IUiIntent.layout` → `app-sidebar`. Planner (`propose-plan.ts`) chooses the archetype from the product description (dashboard only when the user wants a dashboard); `plan-store.ts` already gates on the implemented set. +### Stage 4 — Planner emits `shellLayout` +`propose-plan.ts` picks `shellLayout` from the product (default `app-sidebar`; `dashboard` when the product is a dashboard) + per-slice `navRole`. This is what flips NEW apps to the split; stored plans without the field stay dashboard. Planner-contract tests. ### Stage 5 — Tests + live proof of GENERALITY -Unit tests per seam (both descriptors) + regression (dashboard byte-equal) + split-wiring (idempotent, resume no-op, knip import-chain intact). -**Live acceptance across DIFFERENT apps (the point is generality, not any one app):** -- A **single-entity** feature app — lands on its own app area, settings reachable via gear, CRUD green. -- A **multi-slice relational** app (e.g. a small CRM: Company→Contact→Deal) — proves the app nav-set holds several features + relations, still its own layout, not the demo dashboard. -- A **`dashboard`-archetype** app — proves the legacy path is byte-unchanged. -Each: `boringstack done · N/N verified` + final acceptance green, and manual/e2e confirmation of app-area-primary + Settings/Admin intact. +Per-seam regression (dashboard byte-equal) + split-wiring + schema/migration tests. **Live acceptance across DIFFERENT apps:** (a) a single-entity app, (b) a multi-slice relational app (e.g. Company→Contact→Deal) — several app-role features + a settings-role feature, (c) a `dashboard`-shellLayout app proving the legacy path unchanged. Each: `boringstack done · N/N verified` + final acceptance green, app-area-primary, Settings/Admin intact + reachable via gear, back-to-app works. **Add an e2e for the Settings-area path** (gear → Settings → an account page) so account reachability is gate-covered under the split (v1 missing-test finding). --- ## Critical files -- **New:** `layout-descriptor.ts` (+ tests); nav-set-split wiring (new fn mirroring `applyHomeRedirect`). -- **Modify:** `wire-resource.ts` (`wireUiRouteFile`), `build.ts` (`scopeFor` + `runBoringstackBuild` split call), `refine-prompt.ts` (`layoutGuidance` + closing instruction), `gate.ts` (sidebar test path from descriptor), `acceptance/testid-contract.ts`, `acceptance/e2e-generator.ts`, `planning/plan-types.ts` + `planning/propose-plan.ts`. +- **New:** `layout-descriptor.ts` (+ tests); nav-set-split wiring fn (mirrors `applyHomeRedirect`). +- **Modify:** `plan-types.ts` + `plan-store.ts` (two axes + migration), `propose-plan.ts` (planner emits axes), `wire-resource.ts` (`wireUiRouteFile` navSet), `build.ts` (`scopeFor` signature + `runBoringstackBuild` split call + home default), `gate.ts` (`FAST_GATE` union keyed on shellLayout), `refine-prompt.ts`, `acceptance/testid-contract.ts`, `acceptance/e2e-generator.ts` (app-landing helper + per-area goto). - **Reuse:** `homeRouteForPlan`/`wireHomeRedirectForPlan`/`applyHomeRedirect`, existing `AppShell`/`AppSidebar`, `conventions.ts` guides. -## Risks (biggest first) -1. **Fast-gate sidebar-test path (seam #6, false-green risk)** — the app nav-set's test MUST run in `gate.ts`, or reachability is unverified till final acceptance. Fixed via descriptor `sidebarTestGlob` + a test asserting the fast gate runs it. -2. **Backward compat** — `dashboard` descriptor must equal today's output; per-seam value-equality regression tests. -3. **Nav-set ownership boundary** — app features → app nav-set, settings features → account nav-set; the split-wiring routes each area's sidebar. Deterministic + idempotent, plan-level. -4. **Scope enforcement** — building an app feature must deny edits to the account/demo nav-set; scope-violation test. -5. **Resume-safety/ordering** — split + home redirect run plan-level after both pristine captures + infra fail-closed, skip-if-present (the `#213` ordering trap). -6. **Shared React context** — resolved by reusing `AppShell` (keeps `AppPageHeaderProvider`/`AccountSwitcher`/`useMe`); verify feature pages using `useAppPageHeader` still get it (same shell — they do). +## Risks (biggest first; all from the panel) +1. **Backward-compat / default** — absent `shellLayout` MUST behave exactly like today; the split is strictly opt-in. Regression tests assert the dashboard path is byte-identical AND that absent→dashboard (not app-sidebar). No silent default flip. +2. **Fast-gate union (seam #6, false-green)** — the app nav-set test MUST run in `gate.ts` under app-sidebar; test asserts the fast gate runs the union. +3. **Route-area determinism** — chosen by an explicit wire-time `navSet` prop, never runtime URL guessing; no `/dashboard`/`/notifications` collision. +4. **Two-axis migration** — legacy `layout` maps cleanly to `navRole`; existing `settings` slices keep working; tests cover all legacy shapes. +5. **Scope enforcement** — an app feature is denied edits to the account nav-set and vice-versa; scope-violation test. +6. **Resume/ordering** — split + home run plan-level after both pristine captures + infra fail-closed, marker-guarded skip-if-present; never clobbers model-added app-nav entries. +7. **Home** — app-sidebar with no explicit `home` defaults the redirect to the first app-role slice, never `/dashboard`. +8. **Shared React context** — resolved by reusing `AppShell`. ## Verification -- Harness `bun run validate` green; unit + per-seam regression + split-wiring tests pass. -- Panel-gate every stage's PR via the 4-model `harness-review` (reviewers ok ≥ 2), as always. -- Live: the three different apps above each build to `N/N verified` + final acceptance green, land on their own app area (app nav only), and expose the real account pages via the gear-driven Settings/Admin area. Generality is the acceptance bar — a single app passing is necessary, not sufficient. +- Harness `bun run validate` green; unit + per-seam regression + migration + split-wiring tests pass. +- **Panel-gate every stage's PR via the 4-model `harness-review` (reviewers ok ≥ 2)** — the review of record. +- Live: the three different apps above each reach `N/N verified` + final acceptance green; app-sidebar apps land in the app area (app nav only) with the real account pages reachable via the gear-driven Settings/Admin area (e2e-covered); the dashboard-shellLayout app is byte-unchanged. Generality is the bar — one app passing is necessary, not sufficient. From c78a1ce78facdd7155ac5d6c49ca2bdefb0d08d1 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 15:56:05 +0200 Subject: [PATCH 03/53] =?UTF-8?q?docs(spec):=20app-own-layout=20v3=20?= =?UTF-8?q?=E2=80=94=20additive=20app=20shell=20(approach=20A),=20resolves?= =?UTF-8?q?=20v2=20panel=20BLOCK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch from in-place split to ADDITIVE: under shellLayout=app-sidebar the harness generates a new AppNav + its own open-typed nav file + wraps feature routes in AppShell with optional defaulted sidebar/brandTo props; the demo AppShell/AppSidebar/APP_SIDEBAR_NAV_ITEMS + all 11 account route wrappers stay UNTOUCHED as the Settings area. Real scope enforcement (separate nav file), dashboard path literally unchanged, per-slice navRole axis dropped (all plan features are app features; Settings = pre-existing scaffold pages), home defaults to first feature (no lands-in-Settings hole). One axis (plan.shellLayout); planner emits it. Stages additive/landable. --- .../specs/2026-07-31-app-own-layout-design.md | 137 +++++++----------- 1 file changed, 54 insertions(+), 83 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-app-own-layout-design.md b/docs/superpowers/specs/2026-07-31-app-own-layout-design.md index 026b40ba..966fd9e4 100644 --- a/docs/superpowers/specs/2026-07-31-app-own-layout-design.md +++ b/docs/superpowers/specs/2026-07-31-app-own-layout-design.md @@ -1,110 +1,81 @@ -# Scaffold ANY app in its OWN layout (BoringStack demo dashboard becomes disposable) — Design Spec v2 +# Scaffold ANY app in its OWN layout (BoringStack demo dashboard becomes disposable) — Design Spec v3 -**Status:** approved by the user (2026-07-31). v1 was BLOCKED by the tsforge 4-model panel; v2 addresses every critical/major finding (two-axis model, opt-in default preserving backward-compat, deterministic route-area, existing `settings` archetype folded in, landable re-sequenced stages, gate/e2e/home plumbed against real code). Re-submitted to the panel for the review of record before implementation. +**Status:** approved by the user (2026-07-31), approach (A) chosen. v1 + v2 were BLOCKED by the tsforge 4-model panel. v3 switches to an **additive** architecture that resolves the blocking findings by construction (real scope enforcement, the demo shell + its 11 routes untouched, the `dashboard` path literally unchanged, no per-slice nav-role axis). Re-submitted to the panel for the review of record before implementation. ## Context -The tsforge harness already builds arbitrary **domain features** (any entities/relations/CRUD). What it CANNOT do is give the generated app its **own layout** — it force-wraps **every** feature into BoringStack's demo `AppShell` + global `AppSidebar` through **seven** hardcoded coupling points. So whatever the user asks for comes out as links bolted onto the same showcase dashboard. BoringStack's dashboard is a **disposable showcase**, not the frame every app must live in. A billion possible apps still resolve to a small set of **layout archetypes**; delivering archetype-driven layout — plus not forcing the demo shell — is what makes "scaffold anything" real. `#213` (sidebar grouping + home redirect) never questioned that the demo `AppShell` is the container; this spec undoes that. +The harness already builds arbitrary **domain features**, but force-wraps every one into BoringStack's demo `AppShell` + shared `AppSidebar` (a closed-typed, single-file structure) through several hardcoded coupling points. So every app comes out as links bolted onto the showcase dashboard. The dashboard is a **disposable showcase**, not the frame. Ground truth (verified against the scaffold): the shared `AppSidebar` uses a closed `IAppSidebarNavId` union + a typed icon `Record` + a count-assertion test; `routes.tsx` has **11** hardcoded `` wrappers; features are injected today as entries in the `APP_SIDEBAR_NAV_ITEMS` constant. v1/v2 tried to *split that shared file in-place* and the panel correctly showed it can't be scope-enforced or kept backward-compatible. -**Confirmed with the user:** the scaffold's dashboard/account pages are **real capabilities** (Stripe billing, MFA, OAuth, team, audit, notifications) — keep them all, relocated into a **Settings/Admin area** reached via the header avatar/gear; nothing deleted. The requested app is **primary**, in its **own** layout, and is where you land. Deliver the **general** mechanism; prove it on **several different apps**, not one example. +**Confirmed with the user:** keep the real account pages (billing/MFA/OAuth/team/audit/notifications) — they become the **Settings/Admin area**; nothing deleted. The requested app is primary, in its own layout, where you land. Deliver the general mechanism; prove it on several different apps. ---- +## Approach (A): additive app shell — the demo shell stays untouched as Settings -## Two axes (the v1 critical fix) +For `shellLayout: "app-sidebar"` the harness **generates a new app shell + its own nav file** and wraps the plan's feature routes in it. The scaffold's existing `AppShell`, `AppSidebar`, `APP_SIDEBAR_NAV_ITEMS`, its test, and all **11 account/dashboard route wrappers are left exactly as-is** — they ARE the Settings/Admin area. This is why (A) beats the v2 in-place split: +- **Real scope enforcement** — app features edit a NEW app-nav file at a distinct path; path-glob scope denies them the account `AppSidebar` (the v2 critical is gone — separate files, not two arrays in one). +- **`dashboard`/legacy = literally unchanged** — when `shellLayout` is absent/`"dashboard"`, nothing new is emitted and features go into the existing `APP_SIDEBAR_NAV_ITEMS` exactly as today. No navSet prop on the 11 wrappers, no closed-type edits, no count-test churn. Byte-unchanged, not merely value-equal. +- **No per-slice nav-role** — the Settings area is the pre-existing scaffold pages, not plan features, so every plan slice is an app feature. The whole `navRole` axis (and its "how do we classify settings-role" problem) is dropped. -v1 overloaded `IUiIntent.layout`, which on `main` already means **nav placement within the one shell** (`app-sidebar` | `settings`). Shell choice and per-feature nav placement are **different axes**: +### One axis only +- **`IProductPlan.shellLayout?: "dashboard" | "app-sidebar"`** — absent → `"dashboard"` = today's exact behavior. The **planner emits it** (`app-sidebar` for normal apps; `dashboard` only when the product itself is a dashboard) so new apps get their own layout while stored/legacy plans rebuild identically. Legacy `IUiIntent.layout` stays readable and keeps its current meaning **only on the dashboard path** (nav placement in the one sidebar) — untouched, so legacy `layout:"settings"` demotion still works exactly as today. -- **`IProductPlan.shellLayout?: ShellLayout`** — PLAN-level. `ShellLayout = "dashboard" | "app-sidebar"`. **Absent → `"dashboard"` = today's EXACT single-shell behavior** (backward-compat; no existing plan changes). `"app-sidebar"` = the app owns its layout (app area + Settings/Admin area split). The **planner emits `shellLayout` for every new plan** (`app-sidebar` for normal apps, `dashboard` only when the product IS a dashboard) — so new apps get their own layout by default, while stored/legacy plans with no field rebuild identically. -- **`IUiIntent.navRole?: "app" | "settings"`** — SLICE-level, default `"app"`. Only meaningful when `shellLayout === "app-sidebar"`: which nav-set the feature's link joins (primary app nav vs the Settings/Admin nav). Under `shellLayout: "dashboard"` it is ignored (one nav-set, today's behavior). -- **Legacy `IUiIntent.layout` migration:** keep the field readable; `parsePlan` maps a legacy `layout: "settings"` → `navRole: "settings"`, `layout: "app-sidebar"` → `navRole: "app"`. `layout` no longer selects a shell. The existing `settings` archetype thus becomes a **navRole**, not a shell — resolving the "`getLayoutDescriptor` throws on settings" critical. `LAYOUT_ARCHETYPES`/`IMPLEMENTED_LAYOUT_ARCHETYPES` are retired/renamed to the two-axis types. +### The generated app shell (reuse, don't duplicate; minimal, backward-compatible touch) +- **`AppShell` gains two OPTIONAL, defaulted props** — `sidebar?: ReactNode` (default: today's ``) and `brandTo?: string` (default: `"/dashboard"`). The 11 account wrappers pass neither → **rendered identically to today** (this is the whole backward-compat guarantee; a defaulted optional prop is not a rewrite). This reuses ALL of `AppShell`'s providers/header (`AppPageHeaderProvider`, `AccountSwitcher`, `NotificationBell`, `ThemeToggle`, sign-out) so feature pages that call `useAppPageHeader` keep working — no context re-provisioning, no second provider tree, no import collision. +- App feature routes wrap in `} brandTo={home}>` — same shell, app nav-set, brand → the app home (so the app chrome never yanks you to `/dashboard`). +- **`AppNav`** is a NEW generated component + an **open-typed** nav file (`APP_NAV_ITEMS`, plain `{id,path,labelKey}[]`, no closed union, its own icon handling) that features append to. It ships with its own co-located count test at a known path. -**Descriptor is keyed by the PLAN's `shellLayout`, not per-slice** — resolving the mixed-plan ambiguity (which descriptor drives the shared shell, gate, e2e). Per-slice `navRole` only selects the nav-set within the app-sidebar shell. +### Settings ↔ app cross-navigation (the single account-side touch) +Under `app-sidebar`, one deterministic, marker-guarded edit to the account side: set the account `AppShell`'s `brandTo` default usage so the Settings area has a **"← back to app"** affordance pointing at the app home (either via `brandTo` on the account wrappers or a small back link injected once). The app side reaches Settings via the existing header avatar/gear → `/account/profile`. Both directions are harness-wired, not model-authored. ---- +### Home (never lands in Settings) +`wireHomeRedirectForPlan` sets `DEFAULT_REDIRECT_TO`. Under `app-sidebar`: the `home` slice's route if marked, else the **first feature slice's route** (stable plan order). A plan always has ≥1 feature slice, so there is always an app landing — the "zero app-role slices" / "lands in /dashboard" holes from v2 cannot occur (no navRole; all slices are app features). -## Architecture — reuse the existing AppShell; route-area chosen deterministically at wire-time +### Decision-making (no new agent tools) +Planner picks `shellLayout` (user-approves the plan). Harness applies it deterministically (generate `AppNav` + shell props + wrapper + gate + home). The build agent gets **no new tools / no layout discretion** — under `app-sidebar` it just appends its link to the app-nav file the harness scoped + named; under `dashboard` it does exactly what it does today. -Generating a second `AppShell` invites import collisions + re-providing the account pages' React context (`AppPageHeaderProvider`, `AccountSwitcher`, `useMe`). Instead **reuse the existing `AppShell`** and make it render one of two nav-sets, selected by an **explicit prop set at wire-time** (NOT runtime URL-prefix guessing — that was a v1 critical: `/notifications` isn't under `/account`, and generated apps can collide with `/dashboard`): - -- The route wrapper passes `` for app-role feature routes and `` for the scaffold's account/dashboard/notifications routes. `AppShell` renders the app `AppSidebar` variant or the account one from that prop — deterministic, per-route, collision-proof, and covers the mobile `Sheet` the same way. -- **App area:** `AppShell navSet="app"` + the **app nav-set** (feature slices with `navRole:"app"`); header avatar/gear → Settings. Post-login lands on the app (see Home below). -- **Settings/Admin area:** `AppShell navSet="account"` + the **account nav-set** (the current `APP_SIDEBAR_NAV_ITEMS`: dashboard/notifications/team/audit/settings/billing/profile) + a "← back to app" link. Account pages/routes untouched. -- **`shellLayout: "dashboard"`** keeps exactly one nav-set (`navSet="app"` for all, pointing at today's single `APP_SIDEBAR_NAV_ITEMS`) → byte-identical to today. - -### The seven coupling points (verified `main`) + the new axis threading -1. **Route wrapper** — `wire-resource.ts` `wireUiRouteFile()` (L133-187): emit `…` where `role` derives from the slice's `navRole` under `app-sidebar`, else today's literal. `wireUiRouteFile` gains a descriptor/navSet arg. -2. **Scope** — `build.ts` `scopeFor()` (L162-180): gains a `(name, shellLayout, navRole)` signature; under `app-sidebar`+`navRole:"app"` grants the **app nav-set file + its test** (not the account sidebar); under `dashboard` returns today's globs exactly. -3. **Refine prompt** — `refine-prompt.ts` `layoutGuidance()`/closing instruction: tell an `app` feature to register in the app nav-set, a `settings` feature in the account nav-set. Descriptor-driven text. -4. **Nav-testid contract** — `acceptance/testid-contract.ts`: `nav-` required on whichever nav-set the feature's `navRole` selects. -5. **E2E** — `acceptance/e2e-generator.ts`: `authedPage.dashboard.goto()` appears at MANY step sites (nav/list/create/update/delete/negatives), not just L681-686. Add an **app-area landing helper** (`authedPage.app.goto()` → the app home route). App-role specs start there; settings-role specs keep `dashboard.goto()`. All goto sites are parameterized by the feature's area. -6. **Fast-gate sidebar test path** — `gate.ts` `runBoringstackGate` builds a single project-wide `FAST_GATE` with hardcoded `src/components/core/AppSidebar`. The gate is project-wide (no slice arg), so it must run the **union**: the account sidebar test ALWAYS + the app nav-set test when `plan.shellLayout==="app-sidebar"`. `shellLayout` reaches the gate at the plan/build level (available in `runBoringstackBuild`), not per-slice. -7. **Shell provisioning** — reuse the existing `AppShell`/`AppSidebar`; the new wiring is the nav-set split + the app nav-set constant + its test (below). - -### Nav-set split — code-level (v1 "not designed at the mutation level" fix) -Deterministic, plan-level, idempotent (marker-guarded, skip-if-present), harness-injected (NOT model-authored), run in `runBoringstackBuild` AFTER the pristine gate baseline + `captureMetaBaseline` + infra fail-closed (the `#213` ordering trap), only when `shellLayout==="app-sidebar"`: -- **`AppSidebar`:** split today's `APP_SIDEBAR_NAV_ITEMS` into `ACCOUNT_NAV_ITEMS` (the existing 7) and a new empty `APP_NAV_ITEMS` (feature links append here). `AppSidebar` takes the `navSet` prop and renders the matching list (desktop + mobile `Sheet` parity). Keep `ACCOUNT_NAV_ITEMS` referenced so knip is satisfied; the empty `APP_NAV_ITEMS` is referenced by the `navSet==="app"` branch (import chain intact). -- **App nav-set test:** ships with an initial assertion (baseline link count = 0/however the scaffold seeds it) at the path `gate.ts` runs (seam #6); each app feature bumps it (same already-handled frozen-sibling pattern #46/#65/#81). -- **AppShell:** avatar/gear → Settings (`/account/profile`); the account variant gets a "← back to app" link → the app home route. -- **Resume mid-migration:** the split is marker-guarded so a resumed build doesn't re-split or clobber model-added `APP_NAV_ITEMS` entries. - -### Home (v1 "lands in Settings" fix) -`homeRouteForPlan` today returns null when no slice has `home:true`, leaving `DEFAULT_REDIRECT_TO = /dashboard` (which becomes the Settings area). Under `shellLayout: "app-sidebar"`, if no explicit `home`, **default the redirect to the first `navRole:"app"` slice's route** — never `/dashboard`. So an app-sidebar app always lands in the app area. - -### How the decision is made (no new agent tools) -Planner picks `shellLayout` (+ per-slice `navRole`), user-approves the plan. Harness applies the shell descriptor deterministically. The build agent gets **no new tools and no layout discretion** — it only adds its feature's nav link to the nav-set file the harness scoped + named. (Hard lesson this session: model-decided load-bearing structure → false-green/park.) - -### `IShellDescriptor` (keyed by shellLayout) +### `IShellDescriptor` (keyed by shellLayout, plan-level) ```ts type ShellLayout = "dashboard" | "app-sidebar"; -type NavRole = "app" | "settings"; interface IShellDescriptor { - navSetFor: (role: NavRole) => "app" | "account"; // which AppShell nav-set the route uses - navFileFor: (role: NavRole) => string; // the nav-set file the feature edits - sidebarTestGlobs: string[]; // union the fast gate must run (seam #6) - e2eStartFor: (role: NavRole) => "app" | "dashboard"; // e2e landing helper - splitsNavSets: boolean; // app-sidebar=true, dashboard=false (today) + wrapRoute: (pageJsx: string, ctx: { brandTo: string }) => string; // route element for a feature page + navFile: string; // file a feature appends its nav link to (app-nav vs existing constants) + navGuidance: string; // refine-prompt text for where/how to add the link + navTestId: (camel: string) => string; + sidebarTestGlobs: string[]; // union the fast gate runs (account test always; app-nav test under app-sidebar) + generatesShell: boolean; // app-sidebar=true (emit AppNav + shell props); dashboard=false (today) } -export function getShellDescriptor(shell: ShellLayout): IShellDescriptor // throws on unknown +export function getShellDescriptor(shell: ShellLayout): IShellDescriptor // throws on unknown; only 2 today ``` -The `dashboard` descriptor maps every role to today's single nav-set/file/test/e2e (byte-identical). Honest scope note: this descriptor covers `{dashboard, app-sidebar}` only. Future `public`/`focused`/`top-nav` need additional fields (auth boundary, shell presence, nav mechanism, provisioning) — documented as explicit extension points, NOT claimed-covered now (v1 over-claimed generality). - ---- - -## Stages (re-sequenced so each is real, landable, no dead code, no default-flip) - -### Stage 1 — Two-axis schema + backward-compat (no behavior change) -Add `IProductPlan.shellLayout` + `IUiIntent.navRole`; migrate legacy `layout` (`parsePlan` maps to `navRole`); retire `LAYOUT_ARCHETYPES`/`IMPLEMENTED_LAYOUT_ARCHETYPES` into the two-axis types + `plan-store.ts` validation. **Absent `shellLayout` → "dashboard" (today).** Tests: legacy plans (no field / `layout:"settings"` / `layout:"app-sidebar"`) parse to the correct axes; absent → dashboard. No seam touched yet — pure schema, landable, zero behavior change. +Honest scope note: covers `{dashboard, app-sidebar}` only. `public`/`focused`/`top-nav` need more fields (auth boundary, provisioning) — explicit future extension points, not claimed-covered. -### Stage 2 — `IShellDescriptor` + thread through the seven seams; `dashboard` == today EXACTLY -Add `layout-descriptor.ts` (`getShellDescriptor`, both descriptors) AND wire it through all seams in the SAME stage (no dead-code interim). Every seam resolves `getShellDescriptor(plan.shellLayout ?? "dashboard")`. The `dashboard` path is byte-identical to today (per-seam value-equality regression tests against the current literals). The `app-sidebar` path is new but exercised only when a plan opts in — so landing Stage 2 changes nothing for existing plans (they're dashboard) yet the new path is fully unit-tested. +## Stages (each additive, landable, no dead code, no default-flip) -### Stage 3 — `app-sidebar` end-to-end (behind `shellLayout:"app-sidebar"`) -Nav-set split wiring (code-level above) + app nav-set + its gate-run test + AppShell `navSet` prop + avatar/gear + back-to-app + home default. All gated on `shellLayout==="app-sidebar"`; dashboard untouched. Scope/prompt/testid/e2e/gate all honor `navRole`. Unit + integration tests (split idempotent, resume no-op, knip chain, gate runs the app nav-set test, e2e app-landing helper). +### Stage 1 — `shellLayout` schema + validation (zero behavior change) +Add `IProductPlan.shellLayout` + `plan-store.ts` validation; absent → `"dashboard"`. Legacy `layout` untouched. Planner NOT yet emitting it → every build stays dashboard. Tests: absent→dashboard; explicit values parse; legacy plans unaffected. Landable, no behavior change. -### Stage 4 — Planner emits `shellLayout` -`propose-plan.ts` picks `shellLayout` from the product (default `app-sidebar`; `dashboard` when the product is a dashboard) + per-slice `navRole`. This is what flips NEW apps to the split; stored plans without the field stay dashboard. Planner-contract tests. +### Stage 2 — `app-sidebar` end-to-end behind the flag (additive) +`IShellDescriptor` + `getShellDescriptor`; the `AppShell` optional `sidebar`/`brandTo` props (defaults preserve today); generated `AppNav` + open-typed nav file + its count test; `wireUiRouteFile` branches on the descriptor (app-sidebar → `AppShell sidebar/brandTo`; dashboard → today's literal, unchanged); `scopeFor(name, shellLayout)` grants the app-nav file + its test under app-sidebar (not the account sidebar); `refine-prompt` app-nav guidance; `gate.ts` runs the sidebar-test union keyed on `plan.shellLayout`; `e2e-generator` app-area landing helper; `wireHomeRedirectForPlan` first-feature default; the marker-guarded generation + back-to-app wiring (plan-level, after pristine baselines + `captureMetaBaseline` + infra fail-closed, skip-if-present, resume-safe per file). Exercised by unit tests + a test plan with `shellLayout:"app-sidebar"`. The `dashboard` path is literally untouched. Landable: dashboard unchanged, app-sidebar fully working + tested, opt-in only. -### Stage 5 — Tests + live proof of GENERALITY -Per-seam regression (dashboard byte-equal) + split-wiring + schema/migration tests. **Live acceptance across DIFFERENT apps:** (a) a single-entity app, (b) a multi-slice relational app (e.g. Company→Contact→Deal) — several app-role features + a settings-role feature, (c) a `dashboard`-shellLayout app proving the legacy path unchanged. Each: `boringstack done · N/N verified` + final acceptance green, app-area-primary, Settings/Admin intact + reachable via gear, back-to-app works. **Add an e2e for the Settings-area path** (gear → Settings → an account page) so account reachability is gate-covered under the split (v1 missing-test finding). +### Stage 3 — Planner emits `shellLayout` +`propose-plan.ts` picks `app-sidebar` for normal apps, `dashboard` only for dashboard-shaped products (heuristic: the product's primary purpose is viewing aggregate/overview data with no primary CRUD entity → dashboard; otherwise app-sidebar; default app-sidebar). This flips NEW apps to their own layout; stored plans (no field) stay dashboard. Planner-contract tests. ---- +### Stage 4 — Tests + live proof of GENERALITY +Per-seam tests (both descriptors) + generation/idempotency/resume + gate-union + home-default + scope-denial (real: app feature can't edit account sidebar). **Live across DIFFERENT apps:** (a) single-entity app, (b) multi-slice relational app (Company→Contact→Deal), (c) a `dashboard`-shellLayout app (legacy path unchanged). Each: `boringstack done · N/N verified` + final acceptance green; app-sidebar apps land in the app area with the real account pages reachable via the gear (e2e-covered) + back-to-app working; dashboard app byte-unchanged. ## Critical files -- **New:** `layout-descriptor.ts` (+ tests); nav-set-split wiring fn (mirrors `applyHomeRedirect`). -- **Modify:** `plan-types.ts` + `plan-store.ts` (two axes + migration), `propose-plan.ts` (planner emits axes), `wire-resource.ts` (`wireUiRouteFile` navSet), `build.ts` (`scopeFor` signature + `runBoringstackBuild` split call + home default), `gate.ts` (`FAST_GATE` union keyed on shellLayout), `refine-prompt.ts`, `acceptance/testid-contract.ts`, `acceptance/e2e-generator.ts` (app-landing helper + per-area goto). -- **Reuse:** `homeRouteForPlan`/`wireHomeRedirectForPlan`/`applyHomeRedirect`, existing `AppShell`/`AppSidebar`, `conventions.ts` guides. - -## Risks (biggest first; all from the panel) -1. **Backward-compat / default** — absent `shellLayout` MUST behave exactly like today; the split is strictly opt-in. Regression tests assert the dashboard path is byte-identical AND that absent→dashboard (not app-sidebar). No silent default flip. -2. **Fast-gate union (seam #6, false-green)** — the app nav-set test MUST run in `gate.ts` under app-sidebar; test asserts the fast gate runs the union. -3. **Route-area determinism** — chosen by an explicit wire-time `navSet` prop, never runtime URL guessing; no `/dashboard`/`/notifications` collision. -4. **Two-axis migration** — legacy `layout` maps cleanly to `navRole`; existing `settings` slices keep working; tests cover all legacy shapes. -5. **Scope enforcement** — an app feature is denied edits to the account nav-set and vice-versa; scope-violation test. -6. **Resume/ordering** — split + home run plan-level after both pristine captures + infra fail-closed, marker-guarded skip-if-present; never clobbers model-added app-nav entries. -7. **Home** — app-sidebar with no explicit `home` defaults the redirect to the first app-role slice, never `/dashboard`. -8. **Shared React context** — resolved by reusing `AppShell`. +- **New:** `layout-descriptor.ts` (+tests); generated `AppNav` component + open-typed nav file + its test (templates in the harness); nav-set/shell generation wiring (mirrors `applyHomeRedirect`). +- **Modify:** `plan-types.ts`/`plan-store.ts` (shellLayout), `propose-plan.ts` (planner emits it), `wire-resource.ts` (`wireUiRouteFile` descriptor branch), `build.ts` (`scopeFor` signature + `runBoringstackBuild` generation/home), `gate.ts` (test-glob union keyed on shellLayout), `refine-prompt.ts`, `acceptance/testid-contract.ts`, `acceptance/e2e-generator.ts` (app-area helper). Scaffold side: `AppShell` gains optional `sidebar`/`brandTo` (defaults = today) — the ONLY change to existing shell code, backward-compatible. +- **Untouched:** the 11 existing `` route wrappers, `AppSidebar`, `APP_SIDEBAR_NAV_ITEMS`, `IAppSidebarNavId`, the existing sidebar test — all stay as the Settings area. +- **Reuse:** `homeRouteForPlan`/`wireHomeRedirectForPlan`/`applyHomeRedirect`, `AppShell` providers/header, `conventions.ts` guides. + +## Risks (biggest first) +1. **Backward-compat** — absent/`dashboard` must be byte-unchanged; guaranteed by construction (nothing new emitted; features → existing constants) + regression tests. The `AppShell` optional props must default to today's render (test the account wrappers render identically with no props). +2. **Fast-gate union (false-green)** — under app-sidebar the app-nav test MUST run in `gate.ts`; test asserts it does; the account test still runs. +3. **Scope denial is now real** — app-nav is a separate file; test that an app feature editing the account `AppSidebar` is out-of-scope. +4. **Generated shell reuses providers** — `AppShell` with the `sidebar` prop keeps `AppPageHeaderProvider` etc.; feature pages using `useAppPageHeader` still work (same shell instance). +5. **Resume/ordering** — generation + home + back-to-app run plan-level after both pristine captures + infra fail-closed, marker-guarded, skip-if-present per file; a partial run re-completes on resume without clobbering model-added app-nav entries. +6. **Planner heuristic** — a wrong dashboard/app-sidebar guess is visible in the user-approved plan (human backstop) and defaults to app-sidebar. ## Verification -- Harness `bun run validate` green; unit + per-seam regression + migration + split-wiring tests pass. -- **Panel-gate every stage's PR via the 4-model `harness-review` (reviewers ok ≥ 2)** — the review of record. -- Live: the three different apps above each reach `N/N verified` + final acceptance green; app-sidebar apps land in the app area (app nav only) with the real account pages reachable via the gear-driven Settings/Admin area (e2e-covered); the dashboard-shellLayout app is byte-unchanged. Generality is the bar — one app passing is necessary, not sufficient. +- `bun run validate` green; unit + regression + generation + scope-denial + gate-union tests pass. +- **Panel-gate every stage's PR via the 4-model `harness-review` (reviewers ok ≥ 2)** — review of record. +- Live: the three different apps reach `N/N verified` + final acceptance green; app-sidebar apps land in their own app area with the real account pages reachable via the gear (e2e) + back-to-app; the dashboard app is byte-unchanged. Generality is the bar — one app passing is necessary, not sufficient. From 9c8b0f22a7a5852bb65f155f518c9a00949e2b78 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 16:05:48 +0200 Subject: [PATCH 04/53] =?UTF-8?q?docs(spec):=20app-own-layout=20v4=20?= =?UTF-8?q?=E2=80=94=20nav-as-data=20+=20real=20cross-nav=20+=20honest=20a?= =?UTF-8?q?dditive=20framing=20(resolves=20v3=20panel=20BLOCK)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3 blocked on one coherent cluster: cross-nav vs 'untouched', and sidebar-as-ReactNode vs the twice- mounted (desktop+mobile) sidebar. v4: pass nav as DATA (AppSidebar optional navItems rendered in both mounts) not an element; app sidebar shows a Settings footer link (app->Settings reachable — stock header has none); a conditional back-to-app footer when an app area exists (Settings->app), one additive component change, 9 route wrappers untouched; require >=1 slice under app-sidebar + read the normalized plan (no empty-plan/undefined holes); honest 'behaviorally unchanged for dashboard' (scaffold component gains defaulted optional props, not byte-identical source); frame feature-can-rewrite-sibling + scope-includes-test as parity-with-today, not regressions. Count corrected 11->9. --- .../specs/2026-07-31-app-own-layout-design.md | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-app-own-layout-design.md b/docs/superpowers/specs/2026-07-31-app-own-layout-design.md index 966fd9e4..c6c27389 100644 --- a/docs/superpowers/specs/2026-07-31-app-own-layout-design.md +++ b/docs/superpowers/specs/2026-07-31-app-own-layout-design.md @@ -8,26 +8,28 @@ The harness already builds arbitrary **domain features**, but force-wraps every **Confirmed with the user:** keep the real account pages (billing/MFA/OAuth/team/audit/notifications) — they become the **Settings/Admin area**; nothing deleted. The requested app is primary, in its own layout, where you land. Deliver the general mechanism; prove it on several different apps. -## Approach (A): additive app shell — the demo shell stays untouched as Settings +## Approach (A): additive app nav — the demo shell/routes stay as Settings -For `shellLayout: "app-sidebar"` the harness **generates a new app shell + its own nav file** and wraps the plan's feature routes in it. The scaffold's existing `AppShell`, `AppSidebar`, `APP_SIDEBAR_NAV_ITEMS`, its test, and all **11 account/dashboard route wrappers are left exactly as-is** — they ARE the Settings/Admin area. This is why (A) beats the v2 in-place split: -- **Real scope enforcement** — app features edit a NEW app-nav file at a distinct path; path-glob scope denies them the account `AppSidebar` (the v2 critical is gone — separate files, not two arrays in one). -- **`dashboard`/legacy = literally unchanged** — when `shellLayout` is absent/`"dashboard"`, nothing new is emitted and features go into the existing `APP_SIDEBAR_NAV_ITEMS` exactly as today. No navSet prop on the 11 wrappers, no closed-type edits, no count-test churn. Byte-unchanged, not merely value-equal. -- **No per-slice nav-role** — the Settings area is the pre-existing scaffold pages, not plan features, so every plan slice is an app feature. The whole `navRole` axis (and its "how do we classify settings-role" problem) is dropped. +For `shellLayout: "app-sidebar"` the harness gives the app its **own nav-set (as DATA in a new file)** and has the shared `AppSidebar`/`AppShell` render it, plus cross-navigation links between the app and the Settings area. The scaffold's **9 account/dashboard route wrappers, `APP_SIDEBAR_NAV_ITEMS`, `IAppSidebarNavId`, and the existing sidebar test are left exactly as-is** — they ARE the Settings/Admin area. The shared `AppSidebar`/`AppShell` **components** gain small additive, backward-compatible changes (below). Why (A) beats v2's in-place split: +- **Real scope enforcement** — the app nav *items* live in a NEW file (`APP_NAV_ITEMS`) at a distinct path; path-glob scope denies app features the account `AppSidebar`/`APP_SIDEBAR_NAV_ITEMS`. (v2's critical is gone: separate files, not two arrays in one.) +- **`dashboard`/legacy path = behaviorally unchanged** — when `shellLayout` is absent/`"dashboard"`, the harness emits nothing new and features go into the existing `APP_SIDEBAR_NAV_ITEMS` exactly as today; the new component props default to today's render. (Honest scope: the scaffold `AppSidebar`/`AppShell` *source* gains optional props/one conditional — a one-time template change, defaulted — so it is "behaviorally identical for dashboard builds," NOT "byte-identical scaffold source." The generated per-feature output for a dashboard build is unchanged.) +- **No per-slice nav-role** — the Settings area is the pre-existing scaffold pages, not plan features, so every plan slice is an app feature; the `navRole` axis is dropped. ### One axis only -- **`IProductPlan.shellLayout?: "dashboard" | "app-sidebar"`** — absent → `"dashboard"` = today's exact behavior. The **planner emits it** (`app-sidebar` for normal apps; `dashboard` only when the product itself is a dashboard) so new apps get their own layout while stored/legacy plans rebuild identically. Legacy `IUiIntent.layout` stays readable and keeps its current meaning **only on the dashboard path** (nav placement in the one sidebar) — untouched, so legacy `layout:"settings"` demotion still works exactly as today. +- **`IProductPlan.shellLayout?: "dashboard" | "app-sidebar"`** — absent → `"dashboard"` = today's behavior. The **planner emits it**. Legacy `IUiIntent.layout` keeps its current meaning **only on the dashboard path**, so legacy `layout:"settings"` demotion still works exactly as today. +- **Validation:** an `app-sidebar` plan MUST have ≥ 1 slice (`plan-store.ts` rule); `isProductPlan` currently allows `slices:[]`, so this closes the "empty plan → no app home" hole below. `gate.ts` and every consumer read the **normalized/defaulted** plan from `plan-store` (never the raw file), so an absent `shellLayout` is always seen as `"dashboard"` (no false-green from `undefined`). -### The generated app shell (reuse, don't duplicate; minimal, backward-compatible touch) -- **`AppShell` gains two OPTIONAL, defaulted props** — `sidebar?: ReactNode` (default: today's ``) and `brandTo?: string` (default: `"/dashboard"`). The 11 account wrappers pass neither → **rendered identically to today** (this is the whole backward-compat guarantee; a defaulted optional prop is not a rewrite). This reuses ALL of `AppShell`'s providers/header (`AppPageHeaderProvider`, `AccountSwitcher`, `NotificationBell`, `ThemeToggle`, sign-out) so feature pages that call `useAppPageHeader` keep working — no context re-provisioning, no second provider tree, no import collision. -- App feature routes wrap in `} brandTo={home}>` — same shell, app nav-set, brand → the app home (so the app chrome never yanks you to `/dashboard`). -- **`AppNav`** is a NEW generated component + an **open-typed** nav file (`APP_NAV_ITEMS`, plain `{id,path,labelKey}[]`, no closed union, its own icon handling) that features append to. It ships with its own co-located count test at a known path. +### Nav as DATA + additive, backward-compatible shell changes +The real `AppShell` mounts `AppSidebar` twice (desktop `` + mobile ``), so an opaque element prop can't carry per-mount props. Instead pass **data**: +- **`AppSidebar` gains optional props (all defaulted to today):** `navItems?` (default = today's account items from `APP_SIDEBAR_NAV_ITEMS`) rendered in BOTH mounts; and a `footerLink?: {to,label}` slot. No props → renders exactly as today (the backward-compat guarantee; test both mounts render identically with no props). Feature pages keep `AppShell`'s providers/header (`AppPageHeaderProvider`, `AccountSwitcher`, `NotificationBell`, `ThemeToggle`, sign-out) — no second provider tree, no context loss. +- **App route wrapper:** `` (rendered internally in both mounts) — the app sidebar shows the app's features + a **Settings** footer link, so Settings is reachable (fixing the "app→Settings unreachable" critical: the stock header has no settings link). +- **`APP_NAV_ITEMS`** is a NEW open-typed data file (`{id,path,labelKey}[]`, no closed union) features append to; ships with its own co-located count test. -### Settings ↔ app cross-navigation (the single account-side touch) -Under `app-sidebar`, one deterministic, marker-guarded edit to the account side: set the account `AppShell`'s `brandTo` default usage so the Settings area has a **"← back to app"** affordance pointing at the app home (either via `brandTo` on the account wrappers or a small back link injected once). The app side reaches Settings via the existing header avatar/gear → `/account/profile`. Both directions are harness-wired, not model-authored. +### Settings → app (the single additive component change, not a wrapper edit) +`AppSidebar`, when rendering the **default** (account) nav AND an app area exists (`APP_NAV_ITEMS` non-empty), shows a **"← back to app"** footer link → the app home. This is ONE additive, conditional change to the shared `AppSidebar` component (empty app nav → no link → today's render), so the 9 account route **wrappers stay untouched** and the dashboard path is unaffected. (The desktop brand is a non-link `` today; back-to-app is a real nav link in the sidebar footer, not the brand.) ### Home (never lands in Settings) -`wireHomeRedirectForPlan` sets `DEFAULT_REDIRECT_TO`. Under `app-sidebar`: the `home` slice's route if marked, else the **first feature slice's route** (stable plan order). A plan always has ≥1 feature slice, so there is always an app landing — the "zero app-role slices" / "lands in /dashboard" holes from v2 cannot occur (no navRole; all slices are app features). +`wireHomeRedirectForPlan` sets `DEFAULT_REDIRECT_TO`. Under `app-sidebar`: the `home` slice's route if marked, else the **first slice's route** (stable plan order). Combined with the ≥1-slice validation rule above, there is always an app landing — the v2 "lands in /dashboard" holes cannot occur. ### Decision-making (no new agent tools) Planner picks `shellLayout` (user-approves the plan). Harness applies it deterministically (generate `AppNav` + shell props + wrapper + gate + home). The build agent gets **no new tools / no layout discretion** — under `app-sidebar` it just appends its link to the app-nav file the harness scoped + named; under `dashboard` it does exactly what it does today. @@ -37,11 +39,11 @@ Planner picks `shellLayout` (user-approves the plan). Harness applies it determi type ShellLayout = "dashboard" | "app-sidebar"; interface IShellDescriptor { wrapRoute: (pageJsx: string, ctx: { brandTo: string }) => string; // route element for a feature page - navFile: string; // file a feature appends its nav link to (app-nav vs existing constants) + navFile: string; // file a feature appends its nav link to (APP_NAV_ITEMS vs existing constants) navGuidance: string; // refine-prompt text for where/how to add the link navTestId: (camel: string) => string; - sidebarTestGlobs: string[]; // union the fast gate runs (account test always; app-nav test under app-sidebar) - generatesShell: boolean; // app-sidebar=true (emit AppNav + shell props); dashboard=false (today) + sidebarTestGlobs: string[]; // union the fast gate runs (account test always; APP_NAV_ITEMS test under app-sidebar) + emitsAppNav: boolean; // app-sidebar=true (harness seeds APP_NAV_ITEMS + passes navItems/footerLink); dashboard=false (today) } export function getShellDescriptor(shell: ShellLayout): IShellDescriptor // throws on unknown; only 2 today ``` @@ -53,7 +55,7 @@ Honest scope note: covers `{dashboard, app-sidebar}` only. `public`/`focused`/`t Add `IProductPlan.shellLayout` + `plan-store.ts` validation; absent → `"dashboard"`. Legacy `layout` untouched. Planner NOT yet emitting it → every build stays dashboard. Tests: absent→dashboard; explicit values parse; legacy plans unaffected. Landable, no behavior change. ### Stage 2 — `app-sidebar` end-to-end behind the flag (additive) -`IShellDescriptor` + `getShellDescriptor`; the `AppShell` optional `sidebar`/`brandTo` props (defaults preserve today); generated `AppNav` + open-typed nav file + its count test; `wireUiRouteFile` branches on the descriptor (app-sidebar → `AppShell sidebar/brandTo`; dashboard → today's literal, unchanged); `scopeFor(name, shellLayout)` grants the app-nav file + its test under app-sidebar (not the account sidebar); `refine-prompt` app-nav guidance; `gate.ts` runs the sidebar-test union keyed on `plan.shellLayout`; `e2e-generator` app-area landing helper; `wireHomeRedirectForPlan` first-feature default; the marker-guarded generation + back-to-app wiring (plan-level, after pristine baselines + `captureMetaBaseline` + infra fail-closed, skip-if-present, resume-safe per file). Exercised by unit tests + a test plan with `shellLayout:"app-sidebar"`. The `dashboard` path is literally untouched. Landable: dashboard unchanged, app-sidebar fully working + tested, opt-in only. +`IShellDescriptor` + `getShellDescriptor`; `AppSidebar` optional `navItems`/`footerLink` props (rendered in BOTH desktop + mobile mounts; defaults = today's account items) + conditional "← back to app" footer when an app area exists; `AppShell` optional `brandTo`; the `APP_NAV_ITEMS` open-typed data file + its count test; `wireUiRouteFile` branches on the descriptor (app-sidebar → `AppShell brandTo` wrapping `AppSidebar navItems/footerLink`; dashboard → today's literal, unchanged); `scopeFor(name, shellLayout)` grants the `APP_NAV_ITEMS` file + its test under app-sidebar (not the account sidebar); `refine-prompt` app-nav guidance; `gate.ts` runs the sidebar-test union keyed on the normalized `plan.shellLayout`; `e2e-generator` app-area landing helper (both cross-nav directions); `wireHomeRedirectForPlan` first-slice default; the marker-guarded `APP_NAV_ITEMS` seeding + home wiring (plan-level, after pristine baselines + `captureMetaBaseline` + infra fail-closed, skip-if-present, resume-safe per file). Exercised by unit tests + a test plan with `shellLayout:"app-sidebar"`. The `dashboard` path is behaviorally unchanged. Landable: dashboard unchanged, app-sidebar fully working + tested, opt-in only. ### Stage 3 — Planner emits `shellLayout` `propose-plan.ts` picks `app-sidebar` for normal apps, `dashboard` only for dashboard-shaped products (heuristic: the product's primary purpose is viewing aggregate/overview data with no primary CRUD entity → dashboard; otherwise app-sidebar; default app-sidebar). This flips NEW apps to their own layout; stored plans (no field) stay dashboard. Planner-contract tests. @@ -62,18 +64,21 @@ Add `IProductPlan.shellLayout` + `plan-store.ts` validation; absent → `"dashbo Per-seam tests (both descriptors) + generation/idempotency/resume + gate-union + home-default + scope-denial (real: app feature can't edit account sidebar). **Live across DIFFERENT apps:** (a) single-entity app, (b) multi-slice relational app (Company→Contact→Deal), (c) a `dashboard`-shellLayout app (legacy path unchanged). Each: `boringstack done · N/N verified` + final acceptance green; app-sidebar apps land in the app area with the real account pages reachable via the gear (e2e-covered) + back-to-app working; dashboard app byte-unchanged. ## Critical files -- **New:** `layout-descriptor.ts` (+tests); generated `AppNav` component + open-typed nav file + its test (templates in the harness); nav-set/shell generation wiring (mirrors `applyHomeRedirect`). -- **Modify:** `plan-types.ts`/`plan-store.ts` (shellLayout), `propose-plan.ts` (planner emits it), `wire-resource.ts` (`wireUiRouteFile` descriptor branch), `build.ts` (`scopeFor` signature + `runBoringstackBuild` generation/home), `gate.ts` (test-glob union keyed on shellLayout), `refine-prompt.ts`, `acceptance/testid-contract.ts`, `acceptance/e2e-generator.ts` (app-area helper). Scaffold side: `AppShell` gains optional `sidebar`/`brandTo` (defaults = today) — the ONLY change to existing shell code, backward-compatible. -- **Untouched:** the 11 existing `` route wrappers, `AppSidebar`, `APP_SIDEBAR_NAV_ITEMS`, `IAppSidebarNavId`, the existing sidebar test — all stay as the Settings area. +- **New:** `layout-descriptor.ts` (+tests); the `APP_NAV_ITEMS` open-typed data file (harness template) + its co-located count test; nav/home generation wiring (mirrors `applyHomeRedirect`). +- **Modify (harness):** `plan-types.ts`/`plan-store.ts` (shellLayout + ≥1-slice-under-app-sidebar rule + normalized read), `propose-plan.ts` (planner emits it), `wire-resource.ts` (`wireUiRouteFile` descriptor branch), `build.ts` (`scopeFor` signature + `runBoringstackBuild` generation/home), `gate.ts` (test-glob union keyed on the NORMALIZED shellLayout), `refine-prompt.ts`, `acceptance/testid-contract.ts`, `acceptance/e2e-generator.ts` (app-area helper). +- **Modify (scaffold, additive + backward-compatible):** `AppSidebar` gains optional `navItems`/`footerLink` (defaults = today's account items) rendered in BOTH desktop + mobile mounts, and a conditional "← back to app" footer when an app area exists; `AppShell` gains an optional `brandTo`. No props → identical to today. This is a one-time template change to the shared components (behaviorally identical for dashboard builds). +- **Untouched:** the **9** existing `` route wrappers, `APP_SIDEBAR_NAV_ITEMS`, `IAppSidebarNavId`, the existing sidebar test — the Settings area. - **Reuse:** `homeRouteForPlan`/`wireHomeRedirectForPlan`/`applyHomeRedirect`, `AppShell` providers/header, `conventions.ts` guides. ## Risks (biggest first) -1. **Backward-compat** — absent/`dashboard` must be byte-unchanged; guaranteed by construction (nothing new emitted; features → existing constants) + regression tests. The `AppShell` optional props must default to today's render (test the account wrappers render identically with no props). -2. **Fast-gate union (false-green)** — under app-sidebar the app-nav test MUST run in `gate.ts`; test asserts it does; the account test still runs. -3. **Scope denial is now real** — app-nav is a separate file; test that an app feature editing the account `AppSidebar` is out-of-scope. -4. **Generated shell reuses providers** — `AppShell` with the `sidebar` prop keeps `AppPageHeaderProvider` etc.; feature pages using `useAppPageHeader` still work (same shell instance). -5. **Resume/ordering** — generation + home + back-to-app run plan-level after both pristine captures + infra fail-closed, marker-guarded, skip-if-present per file; a partial run re-completes on resume without clobbering model-added app-nav entries. -6. **Planner heuristic** — a wrong dashboard/app-sidebar guess is visible in the user-approved plan (human backstop) and defaults to app-sidebar. +1. **Backward-compat** — absent/`dashboard` must be behaviorally unchanged; features → existing constants, and the new `AppSidebar`/`AppShell` props default to today's render. Test: both sidebar mounts render identically with no props; a dashboard build's generated feature output is unchanged. +2. **Fast-gate union (false-green)** — under app-sidebar the `APP_NAV_ITEMS` test MUST run in `gate.ts` (union with the account sidebar test), keyed on the normalized shellLayout; test asserts it does. +3. **Scope denial is now real** — app-nav data is a separate file; test that an app feature editing the account `AppSidebar`/`APP_SIDEBAR_NAV_ITEMS` is out-of-scope. +4. **Cross-nav reachability** — app→Settings via the app sidebar's Settings footer link; Settings→app via the conditional back-to-app footer. e2e covers both directions (the stock header has no settings link, so this must be explicit). +5. **Resume/ordering** — generation + home run plan-level after both pristine captures + infra fail-closed, marker-guarded, skip-if-present per file; a partial run re-completes on resume without clobbering model-added `APP_NAV_ITEMS` entries. +6. **Twice-mounted sidebar** — `navItems` is data (not an element), so both desktop and mobile mounts render the app nav + close-on-navigate works; test the mobile mount. +7. **Accepted parity-with-today (NOT regressions):** (a) a feature could rewrite a sibling's `APP_NAV_ITEMS` entry — identical add-only-shared-file discipline as today's `APP_SIDEBAR_NAV_ITEMS`; (b) the feature's scope includes its own nav count-test so it can bump the count — exactly as the existing flow scopes `AppSidebar.test.tsx` today. Both are pre-existing harness properties, not introduced here; noted, not fixed in this spec. +8. **Planner heuristic** — a wrong dashboard/app-sidebar guess is visible in the user-approved plan (human backstop); defaults to app-sidebar. ## Verification - `bun run validate` green; unit + regression + generation + scope-denial + gate-union tests pass. From 33237f055891dfb2e3799886804c833124815c03 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 16:59:12 +0200 Subject: [PATCH 05/53] =?UTF-8?q?docs(plan):=20core<->adapter=20seam=20rec?= =?UTF-8?q?lamation=20=E2=80=94=20grounded=20in=20the=20coupling=20audit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harness-first: reclaim a clean core/adapter boundary so tsforge is a general TS harness and BoringStack is one adapter. Audit found the build-loop seam is already clean (adapter->core via injected interfaces); the leaks are (1) BoringStack conventions content in core loop, (2) boringstack-planning in core + CLI import, (3) web concepts (IUiIntent/layout) in the core plan schema. WS1 conventions->adapter provider, WS2 planner constraints->adapter, WS3 reclaim the plan spine (supersedes app-own-layout's core shellLayout), WS4 mechanical no-core->adapter import rule = the finish line. Each staged + panel-gated. --- ...026-07-31-core-adapter-seam-reclamation.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-31-core-adapter-seam-reclamation.md diff --git a/docs/superpowers/plans/2026-07-31-core-adapter-seam-reclamation.md b/docs/superpowers/plans/2026-07-31-core-adapter-seam-reclamation.md new file mode 100644 index 00000000..4ce6dbe3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-core-adapter-seam-reclamation.md @@ -0,0 +1,37 @@ +# Core ↔ Adapter Seam Reclamation — Plan + +**Why:** tsforge is a general, TypeScript-specialized, local-model-optimized build harness; BoringStack is its FIRST adapter (Phaser next). Over a month of BoringStack focus, adapter concerns leaked into the core. This plan reclaims a clean seam so the core is stack-agnostic and BoringStack (craft, planning, boilerplate, layout) lives entirely in the adapter. See memory `tsforge-north-star` for the governing laws. **Harness-first, BoringStack-second. Every step panel-gated on the diff. Do not break the working harness.** + +## Good news from the audit (branch `feat/app-own-layout`) +The **build-loop seam is already correct**: BoringStack (`runBoringstackBuild`) calls INTO the core `runGreenfield` loop via clean injected interfaces — `IBoringstackHost` (setScope/setGate/setExpertRescueTarget/captureMetaBaseline/send), `IPlanConstraints`, `Exec`, `Session.setGate/setScope`. Core loop has ZERO imports from `loop/boringstack/`. These injection patterns are the MODEL to mirror for every fix below. + +## The real leaks (grounded, ranked) +1. **Conventions content in core.** `loop/conventions.ts` is 100% BoringStack (React/Elysia/shadcn/Drizzle) but is core-located and consumed by core: `session.ts:632` (`buildConventionGuides`), `turn.ts:28/229` (`unseenGuidesForErrors` + `PULL_CONVENTIONS_TOOL` offered when `offerConventions`), `tools/pull-conventions.ts` (`conventionGuide`/`conventionTopics`/`isConventionTopic`), and the `PULL_CONVENTIONS_TOOL` topic enum in `agent/agent.constants.ts`. So EVERY build (a future Phaser game) gets React idioms. +2. **Planner constraints in core.** `loop/planning/boringstack-planning.ts` (BoringStack-specific) is core-located; `cli/repl.ts:47-49,184,883` imports `isBoringstackProject`/`boringstackPlanConstraints` directly — core/CLI depending on the adapter. +3. **Web concepts in the core plan schema.** `loop/planning/plan-types.ts`: `IUiIntent` (`screens: list|detail|form|dashboard`, `nav`, `shows`, `layout`, `home`), `LAYOUT_ARCHETYPES`, `IMPLEMENTED_LAYOUT_ARCHETYPES` — all web/BoringStack, sitting in the core plan. +4. Low: BoringStack-only comments in `greenfield/run.ts:244`; `scaffold/boringstack-manifest.ts` naming. + +## Workstreams (staged, each independently landable + panel-gated) + +### WS1 — Conventions → adapter (via injected provider) +Mirror the `IBoringstackHost`/`cfg`-injection pattern. +- Define a generic `IConventionProvider` in core: `buildGuides(): string`, `unseenForErrors(errors, seen): {topic,guide}[]`, `guide(topic): string | null`, `topics(): string[]`, `isTopic(s): boolean`. +- `ISessionConfig` gains `conventions?: IConventionProvider`. Core consumers read from it: `session.ts:632` → `cfg.conventions?.buildGuides() ?? ""`; `turn.ts` reactive push + tool-offer → `cfg.conventions`; `tools/pull-conventions.ts` → the injected provider (validate topic via `provider.isTopic`); drop/generalize the static topic enum in `agent.constants.ts` (topics come from the provider). +- BoringStack `build-session.ts` constructs the provider from the (relocated) convention library and sets `cfg.conventions`. Absent provider → NO conventions (a generic build gets no React idioms — the whole point). +- **Step A (behavior-preserving):** introduce the provider + injection; BoringStack injects the same guides → identical behavior; generic builds get none. **Step B:** physically move `loop/conventions.ts` → `loop/boringstack/conventions.ts`. +- Keep the `convention-index.test.ts` enum-sync guarantee; update it to the provider. + +### WS2 — Planner constraints → adapter +Move `loop/planning/boringstack-planning.ts` → `loop/boringstack/planning.ts`. `cli/repl.ts` must not import it directly — resolve stack detection + `IPlanConstraints` via a small generic seam (a stack-adapter lookup), so the CLI asks "which adapter for this dir?" and the adapter supplies constraints. `proposePlan` already takes generic `IPlanConstraints` — keep that; only the IMPLEMENTATION location + the CLI import move. + +### WS3 — Reclaim the plan spine (biggest/riskiest; last) +Core `IProductPlan` keeps the general spine: `product`, the domain model (`IEntitySpec` entities/relations), `IVerificationContract`. The web UI (`IUiIntent` screens/nav/shows/layout/home, `LAYOUT_ARCHETYPES`) moves into a **BoringStack plan-extension** validated by the adapter. Mechanism: the slice carries an adapter-typed `presentation`/`ui` extension the core treats opaquely and the adapter validates (mirrors `IPlanConstraints`' generic-in-core / filled-by-adapter split). `parsePlan`/`plan-store` validation splits into a core spine validator + an adapter validator. This unblocks the layout work cleanly (it becomes an adapter concern that can't touch core). **This supersedes the app-own-layout spec's core placement of `shellLayout` — layout is adapter-only.** + +### WS4 — Mechanical boundary (the finish line) +After WS1-3, add a dependency rule (eslint boundaries / dependency-cruiser / custom): **core (`loop/**` except `loop/boringstack/**`, `planning/**` spine, `cli/**`, `agent/**`) MUST NOT import from `loop/boringstack/**`** (and no BoringStack string content in core). A leak becomes a build failure. **This rule passing = the reclamation is done.** + +## Finish line +The boundary rule passes + the core plan is a general spine + a generic build (no adapter) carries no BoringStack conventions/UI concepts. Then: core is stack-agnostic, BoringStack lives fully in the adapter, and the deferred layout/craft/planning/boilerplate work all happens inside the adapter — and Phaser later slots in as a second adapter. + +## Constraints +Harness-first. Don't break the working BoringStack build (it currently reaches full green). Each WS panel-gated on the diff (4-model `harness-review`, reviewers ok ≥ 2). No full end-to-end builds as the driver — unit tests + panel; a build only as an occasional spot-check when a WS completes. From f2ed87b029324d91cbf763a39d94fc318bd84894 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 17:20:09 +0200 Subject: [PATCH 06/53] =?UTF-8?q?refactor(core):=20WS1a=20=E2=80=94=20conv?= =?UTF-8?q?entions=20via=20injected=20IConventionProvider=20seam=20(core?= =?UTF-8?q?=20stops=20importing=20adapter=20content)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the core<->adapter seam reclamation (docs/superpowers/plans/2026-07-31-core-adapter-seam-reclamation.md). - New core interface loop/conventions-provider.ts (IConventionProvider) — the generic seam. - loop/conventions.ts exports boringstackConventionProvider implementing it (the BoringStack CONTENT). - session.ts: the front-loaded guides are now injected via cfg.conventions (removed the static buildConventionGuides import); gate is provider-presence, not the pullConventions flag. - build-config.ts (adapter) injects boringstackConventionProvider into the build session. - Behavior preserved (BoringStack sets the provider → same guides; a plain/non-web build gets none). Residual (WS1b): turn.ts reactive push + pull_conventions tool + agent.constants enum + relocating conventions.ts into loop/boringstack/. Validate green (3228). --- .../core/src/loop/boringstack/build-config.ts | 7 ++++++ .../core/src/loop/conventions-provider.ts | 25 +++++++++++++++++++ packages/core/src/loop/conventions.ts | 16 ++++++++++++ packages/core/src/loop/session.ts | 9 +++++-- .../tests/boringstack-build-config.test.ts | 6 +++++ packages/core/tests/convention-index.test.ts | 2 ++ .../tests/drive-to-green-check-prompt.test.ts | 2 ++ 7 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/loop/conventions-provider.ts diff --git a/packages/core/src/loop/boringstack/build-config.ts b/packages/core/src/loop/boringstack/build-config.ts index 457078a3..3dc74a58 100644 --- a/packages/core/src/loop/boringstack/build-config.ts +++ b/packages/core/src/loop/boringstack/build-config.ts @@ -1,5 +1,7 @@ import type { ExecutionMode } from "../prompt"; import type { IPolicyRules } from "../../policy"; +import type { IConventionProvider } from "../conventions-provider"; +import { boringstackConventionProvider } from "../conventions"; /** * Commands the model must NEVER run during a BoringStack build. The browser end-to-end @@ -30,6 +32,9 @@ const NO_BROWSER_E2E_DENY: IPolicyRules = { * - `executionMode`: the strict expert-TS drive-to-green contract from the first token. * - `guidance`: the per-resource framing (edit only the named files; real domain logic). * - `pullConventions`: offer the convention library the model can fetch on demand. + * - `conventions`: INJECT the BoringStack convention library as the generic + * `IConventionProvider` seam, so the core loop draws the front-loaded guides from + * here instead of importing stack-specific content (core↔adapter seam). * - `offerCheck`: offer the callable, structured `check` tool (WS-G) — the per-slice * gate is injected via setGate, and check runs THAT gate mid-turn. */ @@ -37,6 +42,7 @@ export const BORINGSTACK_BUILD_SESSION: { readonly executionMode: ExecutionMode; readonly guidance: string; readonly pullConventions: true; + readonly conventions: IConventionProvider; readonly offerCheck: true; readonly policyRules: IPolicyRules; } = { @@ -48,6 +54,7 @@ export const BORINGSTACK_BUILD_SESSION: { "(never an `as` cast), and write the required test siblings. Everything else " + "is locked.", pullConventions: true, + conventions: boringstackConventionProvider, offerCheck: true, policyRules: NO_BROWSER_E2E_DENY, }; diff --git a/packages/core/src/loop/conventions-provider.ts b/packages/core/src/loop/conventions-provider.ts new file mode 100644 index 00000000..27e052b0 --- /dev/null +++ b/packages/core/src/loop/conventions-provider.ts @@ -0,0 +1,25 @@ +/** + * Generic CONVENTION-PROVIDER seam. A build ADAPTER (e.g. boringstack) ships a + * "how to write it right" convention library; this interface lets the adapter INJECT + * that library into the core session/turn via `ISessionConfig.conventions`, so the + * core loop never imports stack-specific convention CONTENT. A session with no + * provider (a plain/scratch build, or a future non-web adapter) simply carries no + * conventions — exactly as intended. Mirrors the other injected seams (`IGate`, + * `Exec`, `IPlanConstraints`): the capability is core, the content is the adapter's. + */ +export interface IConventionProvider { + /** The full front-loaded guide text (was `buildConventionGuides`). */ + buildGuides(): string; + /** Reactive PUSH: guides for gate errors not yet seen this run (was + * `unseenGuidesForErrors`). Mutates `seen` to dedupe per run. */ + unseenForErrors( + errors: readonly { readonly rule?: string }[], + seen: Set + ): string[]; + /** On-demand PULL lookup for one topic; null for an unknown topic. */ + guide(topic: string): string | null; + /** The valid topic ids (for the `pull_conventions` tool's help text). */ + topics(): readonly string[]; + /** Whether a string is a known topic. */ + isTopic(s: string): boolean; +} diff --git a/packages/core/src/loop/conventions.ts b/packages/core/src/loop/conventions.ts index 1f3ed315..b4862a3e 100644 --- a/packages/core/src/loop/conventions.ts +++ b/packages/core/src/loop/conventions.ts @@ -11,6 +11,8 @@ * wall. Each guide maps 1:1 to the rules that reject its violation. */ +import type { IConventionProvider } from "./conventions-provider"; + /** The convention topics the model can be handed or pull. Single source of truth: * the const tuple drives both the type and the runtime list (no `as` cast). */ const TOPICS = [ @@ -457,3 +459,17 @@ export function unseenGuidesForErrors( return out; } + +/** + * The BoringStack convention library packaged as the generic `IConventionProvider` + * seam. The core session/turn/tools depend on the INTERFACE (injected via + * `ISessionConfig.conventions`); this concrete provider — the BoringStack CONTENT — + * is supplied by the boringstack adapter (`build-config.ts`). Core never imports it. + */ +export const boringstackConventionProvider: IConventionProvider = { + buildGuides: buildConventionGuides, + unseenForErrors: unseenGuidesForErrors, + guide: (topic) => (isConventionTopic(topic) ? conventionGuide(topic) : null), + topics: conventionTopics, + isTopic: isConventionTopic, +}; diff --git a/packages/core/src/loop/session.ts b/packages/core/src/loop/session.ts index 554b8125..fedae557 100644 --- a/packages/core/src/loop/session.ts +++ b/packages/core/src/loop/session.ts @@ -36,7 +36,7 @@ import { type ErrorSet, } from "../validate"; import { ruleHelp } from "./feedback"; -import { buildConventionGuides } from "./conventions"; +import type { IConventionProvider } from "./conventions-provider"; import { detectStack } from "../stack-detection"; import { recallMapBlock } from "../codebase"; import { @@ -176,6 +176,11 @@ export interface ISessionConfig { * a convention library (e.g. boringstack) so the model can fetch its how-to * patterns on demand. Decoupled from any flag: a plain session leaves it off. */ pullConventions?: boolean; + /** The build ADAPTER's convention library, injected as a generic provider (see + * `IConventionProvider`). Present ⇒ the front-loaded guides + reactive push + the + * `pull_conventions` tool draw from it; absent ⇒ no stack conventions (a plain or + * non-web build). Keeps stack-specific CONTENT out of the core loop. */ + conventions?: IConventionProvider; /** Offer the callable, structured `check` tool (WS-G) — set by a build BACKEND * whose gate is authoritative (e.g. boringstack, which injects its gate per-slice * via `setGate`). The tool runs the SAME full evaluation `settleGate` does @@ -629,7 +634,7 @@ function systemPrompt( // fix. The reactive PUSH (unseenGuidesForErrors) and `pull_conventions` remain as fallbacks // for reinforcement and the long tail, not the primary teaching. const conv = - cfg.pullConventions === true ? `${buildConventionGuides()}\n\n` : ""; + cfg.conventions !== undefined ? `${cfg.conventions.buildGuides()}\n\n` : ""; const contract = taskContract(cfg.files ?? [], cfg.accept); diff --git a/packages/core/tests/boringstack-build-config.test.ts b/packages/core/tests/boringstack-build-config.test.ts index 1fcd809b..af3892b1 100644 --- a/packages/core/tests/boringstack-build-config.test.ts +++ b/packages/core/tests/boringstack-build-config.test.ts @@ -30,6 +30,12 @@ test("headless-build.ts wires its host session via createBoringstackHostSession, test("the BoringStack build flags keep offerCheck + convention library + drive-to-green", () => { expect(BORINGSTACK_BUILD_SESSION.offerCheck).toBe(true); expect(BORINGSTACK_BUILD_SESSION.pullConventions).toBe(true); + // The convention library is INJECTED as the generic provider seam (core no longer + // imports the stack-specific content); dropping it un-front-loads the guides. + expect(BORINGSTACK_BUILD_SESSION.conventions).toBeDefined(); + expect(typeof BORINGSTACK_BUILD_SESSION.conventions.buildGuides()).toBe( + "string" + ); expect(BORINGSTACK_BUILD_SESSION.executionMode).toBe("drive-to-green"); }); diff --git a/packages/core/tests/convention-index.test.ts b/packages/core/tests/convention-index.test.ts index 3437aa62..030a5ff0 100644 --- a/packages/core/tests/convention-index.test.ts +++ b/packages/core/tests/convention-index.test.ts @@ -7,6 +7,7 @@ import { conventionGuide, conventionTopics, topicForRule, + boringstackConventionProvider, } from "../src/loop/conventions"; import { PULL_CONVENTIONS_TOOL } from "../src/agent/agent.constants"; import type { IProvider, IChatMessage } from "../src/inference"; @@ -67,6 +68,7 @@ test("the convention guides are in the system prompt with pullConventions, absen files: ["**/*"], executionMode: "drive-to-green", pullConventions: true, + conventions: boringstackConventionProvider, }); await on.send("go"); diff --git a/packages/core/tests/drive-to-green-check-prompt.test.ts b/packages/core/tests/drive-to-green-check-prompt.test.ts index a5292210..e0a84442 100644 --- a/packages/core/tests/drive-to-green-check-prompt.test.ts +++ b/packages/core/tests/drive-to-green-check-prompt.test.ts @@ -6,6 +6,7 @@ import { buildDriveToGreenSystem } from "../src/loop/prompt/prompt"; import { DEFAULT_CONVENTIONS } from "../src/infer-rules/conventions"; import type { IProvider, IChatMessage } from "../src/inference"; import { Session } from "../src/loop"; +import { boringstackConventionProvider } from "../src/loop/conventions"; // WS-A4: the drive-to-green system prompt must not CONTRADICT the check tool. When // check is offered (the boringstack build), the execution guidance promotes it; when @@ -188,6 +189,7 @@ test("a resumed pullConventions session (no offerCheck) refreshes to include the files: ["**/*"], executionMode: "drive-to-green", pullConventions: true, + conventions: boringstackConventionProvider, history: staleHistory, }); From c967f89f453796ac332decc4201df33fd20f6b70 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 17:31:33 +0200 Subject: [PATCH 07/53] refactor(core): WS1a hardening (panel r1 advisories) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-couple the front-load gate to pullConventions but SOURCE content from cfg.conventions (kills the transient decouple foot-gun: no config front-loads guides while advertising a pull tool it wasn't offered, or vice-versa). - Trim IConventionProvider to buildGuides() (WS1a's only consumer) — no speculative surface; WS1b grows it when the push/tool migrate. Provider trimmed to match. - Correct the overstated seam docs (only front-load draws from the provider in WS1a). - Add a seam-locking test: pullConventions:true with NO provider ⇒ no guides (a revert to the static buildConventionGuides import fails it). Validate green (3229). --- .../core/src/loop/conventions-provider.ts | 18 ++++--------- packages/core/src/loop/conventions.ts | 13 +++++----- packages/core/src/loop/session.ts | 11 +++++--- packages/core/tests/convention-index.test.ts | 25 +++++++++++++++++++ 4 files changed, 43 insertions(+), 24 deletions(-) diff --git a/packages/core/src/loop/conventions-provider.ts b/packages/core/src/loop/conventions-provider.ts index 27e052b0..df0d5731 100644 --- a/packages/core/src/loop/conventions-provider.ts +++ b/packages/core/src/loop/conventions-provider.ts @@ -8,18 +8,10 @@ * `Exec`, `IPlanConstraints`): the capability is core, the content is the adapter's. */ export interface IConventionProvider { - /** The full front-loaded guide text (was `buildConventionGuides`). */ + /** The full front-loaded guide text injected into the system prompt (was the direct + * `buildConventionGuides` call). WS1a consumes only this. The reactive PUSH + * (`unseenGuidesForErrors`) and the `pull_conventions` tool still read the library + * directly; WS1b migrates them here and this interface grows the matching methods + * then — no speculative surface before a consumer exists. */ buildGuides(): string; - /** Reactive PUSH: guides for gate errors not yet seen this run (was - * `unseenGuidesForErrors`). Mutates `seen` to dedupe per run. */ - unseenForErrors( - errors: readonly { readonly rule?: string }[], - seen: Set - ): string[]; - /** On-demand PULL lookup for one topic; null for an unknown topic. */ - guide(topic: string): string | null; - /** The valid topic ids (for the `pull_conventions` tool's help text). */ - topics(): readonly string[]; - /** Whether a string is a known topic. */ - isTopic(s: string): boolean; } diff --git a/packages/core/src/loop/conventions.ts b/packages/core/src/loop/conventions.ts index b4862a3e..62528226 100644 --- a/packages/core/src/loop/conventions.ts +++ b/packages/core/src/loop/conventions.ts @@ -461,15 +461,14 @@ export function unseenGuidesForErrors( } /** - * The BoringStack convention library packaged as the generic `IConventionProvider` - * seam. The core session/turn/tools depend on the INTERFACE (injected via + * The BoringStack front-loaded guides packaged as the generic `IConventionProvider` + * seam. The core session's system prompt depends on the INTERFACE (injected via * `ISessionConfig.conventions`); this concrete provider — the BoringStack CONTENT — - * is supplied by the boringstack adapter (`build-config.ts`). Core never imports it. + * is supplied by the boringstack adapter (`build-config.ts`), so the session no longer + * imports `buildConventionGuides` directly. (WS1a scope: the reactive push + the + * `pull_conventions` tool still import this module directly — they migrate to the + * provider, and this module relocates into `loop/boringstack/`, in WS1b.) */ export const boringstackConventionProvider: IConventionProvider = { buildGuides: buildConventionGuides, - unseenForErrors: unseenGuidesForErrors, - guide: (topic) => (isConventionTopic(topic) ? conventionGuide(topic) : null), - topics: conventionTopics, - isTopic: isConventionTopic, }; diff --git a/packages/core/src/loop/session.ts b/packages/core/src/loop/session.ts index fedae557..f3413427 100644 --- a/packages/core/src/loop/session.ts +++ b/packages/core/src/loop/session.ts @@ -177,9 +177,10 @@ export interface ISessionConfig { * patterns on demand. Decoupled from any flag: a plain session leaves it off. */ pullConventions?: boolean; /** The build ADAPTER's convention library, injected as a generic provider (see - * `IConventionProvider`). Present ⇒ the front-loaded guides + reactive push + the - * `pull_conventions` tool draw from it; absent ⇒ no stack conventions (a plain or - * non-web build). Keeps stack-specific CONTENT out of the core loop. */ + * `IConventionProvider`), so the core no longer imports stack-specific CONTENT for + * the system prompt. WS1a: the FRONT-LOADED guides come from here (gated, with + * `pullConventions`, on a backend that ships a library). The reactive push + the + * `pull_conventions` tool still read the library directly until WS1b migrates them. */ conventions?: IConventionProvider; /** Offer the callable, structured `check` tool (WS-G) — set by a build BACKEND * whose gate is authoritative (e.g. boringstack, which injects its gate per-slice @@ -634,7 +635,9 @@ function systemPrompt( // fix. The reactive PUSH (unseenGuidesForErrors) and `pull_conventions` remain as fallbacks // for reinforcement and the long tail, not the primary teaching. const conv = - cfg.conventions !== undefined ? `${cfg.conventions.buildGuides()}\n\n` : ""; + cfg.pullConventions === true + ? `${cfg.conventions?.buildGuides() ?? ""}\n\n` + : ""; const contract = taskContract(cfg.files ?? [], cfg.accept); diff --git a/packages/core/tests/convention-index.test.ts b/packages/core/tests/convention-index.test.ts index 030a5ff0..63f36d51 100644 --- a/packages/core/tests/convention-index.test.ts +++ b/packages/core/tests/convention-index.test.ts @@ -93,6 +93,31 @@ test("the convention guides are in the system prompt with pullConventions, absen } }); +test("front-loaded guides come from the INJECTED provider, not a static import", async () => { + // Locks the WS1a seam: with pullConventions on but NO provider injected, the guides must be + // ABSENT. A revert to session.ts importing buildConventionGuides directly would re-inject the + // BoringStack text here and fail — proving the core no longer sources the content itself. + const dir = await mkdtemp(join(tmpdir(), "tsforge-conv-seam-")); + + try { + const cap = { system: "" }; + const s = await Session.create({ + provider: systemCapturingProvider(cap), + cwd: dir, + files: ["**/*"], + executionMode: "drive-to-green", + pullConventions: true, + }); + + await s.send("go"); + + expect(cap.system).not.toContain("HOW THIS STACK WRITES CODE"); + expect(cap.system).not.toContain("@/lib/api/client"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + // The pull_conventions tool enum is a hand-maintained duplicate of TOPICS — this locks it to // conventionTopics() so a new topic (or a dropped one, like data-fetching was) can't silently // diverge, leaving a guide the model can't actually pull. From c434bdebb9db4b059a4fbe50016c418e6a466218 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 17:45:14 +0200 Subject: [PATCH 08/53] =?UTF-8?q?test(core):=20WS1a=20=E2=80=94=20independ?= =?UTF-8?q?ently=20verify=20both=20halves=20of=20the=20conventions=20seam?= =?UTF-8?q?=20(panel=20r2=20BLOCK)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FAKE-provider test: sentinel content reaches the prompt AND the real BoringStack lib does NOT — proves the guides are INJECTED, not statically imported (a revert fails it). - Gate test: provider present but pullConventions omitted ⇒ no front-load (the flag still governs). - Mirrored conventions-provider.test.ts: pins the interface contract via the concrete provider. - build-config: assert REAL guide content, not just typeof string (empty provider now fails). Validate green (3232). --- .../tests/boringstack-build-config.test.ts | 6 ++- packages/core/tests/convention-index.test.ts | 49 +++++++++++++++++++ .../core/tests/conventions-provider.test.ts | 15 ++++++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 packages/core/tests/conventions-provider.test.ts diff --git a/packages/core/tests/boringstack-build-config.test.ts b/packages/core/tests/boringstack-build-config.test.ts index af3892b1..be4ca14b 100644 --- a/packages/core/tests/boringstack-build-config.test.ts +++ b/packages/core/tests/boringstack-build-config.test.ts @@ -33,8 +33,10 @@ test("the BoringStack build flags keep offerCheck + convention library + drive-t // The convention library is INJECTED as the generic provider seam (core no longer // imports the stack-specific content); dropping it un-front-loads the guides. expect(BORINGSTACK_BUILD_SESSION.conventions).toBeDefined(); - expect(typeof BORINGSTACK_BUILD_SESSION.conventions.buildGuides()).toBe( - "string" + // Assert REAL guide content, not merely a string — an empty/stub provider would silently + // un-front-load the guides on the live host path (createBoringstackHostSession spreads this). + expect(BORINGSTACK_BUILD_SESSION.conventions.buildGuides()).toContain( + "HOW THIS STACK WRITES CODE" ); expect(BORINGSTACK_BUILD_SESSION.executionMode).toBe("drive-to-green"); }); diff --git a/packages/core/tests/convention-index.test.ts b/packages/core/tests/convention-index.test.ts index 63f36d51..e88947a7 100644 --- a/packages/core/tests/convention-index.test.ts +++ b/packages/core/tests/convention-index.test.ts @@ -118,6 +118,55 @@ test("front-loaded guides come from the INJECTED provider, not a static import", } }); +test("the INJECTED provider's guide content reaches the prompt — not a static import", async () => { + // The decisive seam test: a FAKE provider returns a sentinel. If session.ts sourced the guides + // itself (static import) the sentinel would be ABSENT and the BoringStack text PRESENT. So we + // assert the sentinel IS present and the real BoringStack lib is NOT — content is injected. + const dir = await mkdtemp(join(tmpdir(), "tsforge-conv-inject-")); + + try { + const cap = { system: "" }; + const s = await Session.create({ + provider: systemCapturingProvider(cap), + cwd: dir, + files: ["**/*"], + executionMode: "drive-to-green", + pullConventions: true, + conventions: { buildGuides: () => "FAKE_GUIDE_SENTINEL_9Z" }, + }); + + await s.send("go"); + + expect(cap.system).toContain("FAKE_GUIDE_SENTINEL_9Z"); + expect(cap.system).not.toContain("HOW THIS STACK WRITES CODE"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("the pullConventions gate still governs — a provider WITHOUT the flag does not front-load", async () => { + // Independently verifies the OTHER half: provider present, but pullConventions omitted ⇒ no + // front-load. Guards against a regression to gating on provider-presence alone. + const dir = await mkdtemp(join(tmpdir(), "tsforge-conv-gate-")); + + try { + const cap = { system: "" }; + const s = await Session.create({ + provider: systemCapturingProvider(cap), + cwd: dir, + files: ["**/*"], + executionMode: "drive-to-green", + conventions: { buildGuides: () => "FAKE_GUIDE_SENTINEL_9Z" }, + }); + + await s.send("go"); + + expect(cap.system).not.toContain("FAKE_GUIDE_SENTINEL_9Z"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + // The pull_conventions tool enum is a hand-maintained duplicate of TOPICS — this locks it to // conventionTopics() so a new topic (or a dropped one, like data-fetching was) can't silently // diverge, leaving a guide the model can't actually pull. diff --git a/packages/core/tests/conventions-provider.test.ts b/packages/core/tests/conventions-provider.test.ts new file mode 100644 index 00000000..54adbf01 --- /dev/null +++ b/packages/core/tests/conventions-provider.test.ts @@ -0,0 +1,15 @@ +import { test, expect } from "bun:test"; +import type { IConventionProvider } from "../src/loop/conventions-provider"; +import { boringstackConventionProvider } from "../src/loop/conventions"; + +// Mirrored test for the IConventionProvider seam (loop/conventions-provider.ts). The interface is +// type-only, so this pins its CONTRACT via the concrete BoringStack implementation: assignability +// (a compile-time check the const annotation enforces) plus a real, non-empty guide body — so an +// empty/wrong provider can't slip through claiming to satisfy the seam. +test("boringstackConventionProvider satisfies IConventionProvider with real guide content", () => { + const provider: IConventionProvider = boringstackConventionProvider; + const guides = provider.buildGuides(); + + expect(guides.length).toBeGreaterThan(0); + expect(guides).toContain("HOW THIS STACK WRITES CODE"); +}); From efad0fa649bcd92eb59d3337741ae7199702fd91 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 22:00:45 +0200 Subject: [PATCH 09/53] =?UTF-8?q?refactor(core):=20WS1b=20=E2=80=94=20migr?= =?UTF-8?q?ate=20reactive=20push=20+=20pull=5Fconventions=20tool=20to=20th?= =?UTF-8?q?e=20injected=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last two core consumers of the BoringStack convention CONTENT now read the injected IConventionProvider instead of importing loop/conventions: - IConventionProvider grows unseenForErrors/guide/topics/isTopic (now consumed) + provider impls them. - Provider threaded onto ILoopCtx.tool → spread into every IToolContext (tool) AND read by injectFeedback (push). - turn.ts reactive push: ctx.tool.conventions.unseenForErrors (dropped the ./conventions import). - pull_conventions tool: reads ctx.conventions (guide/topics), no provider ⇒ a clear message; ctx narrowed to Pick. - session.ts sets ctx.tool.conventions = cfg.conventions. Result: NO core file outside loop/boringstack/ imports the convention content. Tests updated to inject the provider (fake + real). Validate green (3233). Residual: agent.constants topic enum, flag consolidation, relocating conventions.ts into loop/boringstack/. --- .../core/src/loop/conventions-provider.ts | 17 +++++++--- packages/core/src/loop/conventions.ts | 4 +++ packages/core/src/loop/session.ts | 5 +++ .../core/src/loop/tools/pull-conventions.ts | 31 ++++++++++++------- packages/core/src/loop/tools/tool-context.ts | 5 +++ packages/core/src/loop/turn.ts | 11 +++++-- .../tests/boringstack-conventions.test.ts | 5 ++- packages/core/tests/convention-index.test.ts | 15 +++++++-- packages/core/tests/pull-conventions.test.ts | 24 +++++++++++--- 9 files changed, 91 insertions(+), 26 deletions(-) diff --git a/packages/core/src/loop/conventions-provider.ts b/packages/core/src/loop/conventions-provider.ts index df0d5731..fa62adb0 100644 --- a/packages/core/src/loop/conventions-provider.ts +++ b/packages/core/src/loop/conventions-provider.ts @@ -9,9 +9,18 @@ */ export interface IConventionProvider { /** The full front-loaded guide text injected into the system prompt (was the direct - * `buildConventionGuides` call). WS1a consumes only this. The reactive PUSH - * (`unseenGuidesForErrors`) and the `pull_conventions` tool still read the library - * directly; WS1b migrates them here and this interface grows the matching methods - * then — no speculative surface before a consumer exists. */ + * `buildConventionGuides` call). */ buildGuides(): string; + /** Reactive PUSH: the guides for gate-error rules not yet seen this run (was + * `unseenGuidesForErrors`). Mutates `seen` to dedupe per run. */ + unseenForErrors( + errors: readonly { readonly rule?: string }[], + seen: Set + ): string[]; + /** On-demand PULL: the guide for one topic; null if the topic is unknown. */ + guide(topic: string): string | null; + /** The valid topic ids (for the `pull_conventions` tool's help text). */ + topics(): readonly string[]; + /** Whether a string is a known topic. */ + isTopic(s: string): boolean; } diff --git a/packages/core/src/loop/conventions.ts b/packages/core/src/loop/conventions.ts index 62528226..da17a4de 100644 --- a/packages/core/src/loop/conventions.ts +++ b/packages/core/src/loop/conventions.ts @@ -471,4 +471,8 @@ export function unseenGuidesForErrors( */ export const boringstackConventionProvider: IConventionProvider = { buildGuides: buildConventionGuides, + unseenForErrors: unseenGuidesForErrors, + guide: (topic) => (isConventionTopic(topic) ? conventionGuide(topic) : null), + topics: conventionTopics, + isTopic: isConventionTopic, }; diff --git a/packages/core/src/loop/session.ts b/packages/core/src/loop/session.ts index f3413427..60429d97 100644 --- a/packages/core/src/loop/session.ts +++ b/packages/core/src/loop/session.ts @@ -951,6 +951,11 @@ export class Session { // `humanPresent`, NOT `interactive` — the latter is a POLICY signal (approval // path) and co-pilot presence must not loosen policy verdicts. ...(cfg.interactive === true ? { humanPresent: true } : {}), + // The adapter's convention library — spread into every IToolContext (so + // `pull_conventions` reads its guides) and read by the reactive push. + ...(cfg.conventions === undefined + ? {} + : { conventions: cfg.conventions }), }, gate: { parse: cfg.parse, diff --git a/packages/core/src/loop/tools/pull-conventions.ts b/packages/core/src/loop/tools/pull-conventions.ts index f501a606..0c1b1359 100644 --- a/packages/core/src/loop/tools/pull-conventions.ts +++ b/packages/core/src/loop/tools/pull-conventions.ts @@ -1,25 +1,32 @@ import { str } from "./tool-context"; -import { - conventionGuide, - conventionTopics, - isConventionTopic, -} from "../conventions"; +import type { IToolContext } from "./tool-context"; /** * The `pull_conventions` tool handler — the PULL half of the convention layer. The - * model fetches the boringstack how-to for a topic ON DEMAND (before writing that - * kind of code), the complement to the harness PUSHing guides on first violation. - * Pure lookup; no ctx needed (fewer params is assignable to ToolHandler). + * model fetches the stack's how-to for a topic ON DEMAND (before writing that kind of + * code), the complement to the harness PUSHing guides on first violation. Reads the + * convention library from the injected `IConventionProvider` (`ctx.conventions`) — the + * core tool stays stack-agnostic; the boringstack adapter supplies the content. */ -export function doPullConventions(args: Record): string { +export function doPullConventions( + args: Record, + ctx: Pick +): string { + const provider = ctx.conventions; + + if (provider === undefined) { + return "pull_conventions: no convention library is configured for this build."; + } + const topic = str(args, "topic"); + const guide = provider.guide(topic); - if (!isConventionTopic(topic)) { + if (guide === null) { return ( `pull_conventions: unknown topic "${topic}". ` + - `Valid topics: ${conventionTopics().join(", ")}.` + `Valid topics: ${provider.topics().join(", ")}.` ); } - return conventionGuide(topic); + return guide; } diff --git a/packages/core/src/loop/tools/tool-context.ts b/packages/core/src/loop/tools/tool-context.ts index 97abc3ec..3eb36928 100644 --- a/packages/core/src/loop/tools/tool-context.ts +++ b/packages/core/src/loop/tools/tool-context.ts @@ -5,6 +5,7 @@ import type { SessionSnapshotStore } from "../../files/hashline"; import type { McpRegistry } from "../../mcp"; import type { PolicyMode, IPolicyRules } from "../../policy"; import type { IValidateResult } from "../../validate/validate.types"; +import type { IConventionProvider } from "../conventions-provider"; /** What one on-demand gate run produced for the `check` tool: the standard * validate result PLUS the files the gate's autofix reformatted/rewrote on disk @@ -93,6 +94,10 @@ export function composeGuards(...guards: readonly EditGuard[]): EditGuard { export interface IToolContext { cwd: string; + /** The build ADAPTER's convention library (injected seam) — the `pull_conventions` + * tool reads its `guide`/`topics`/`isTopic` from here instead of importing stack + * content. Absent ⇒ no convention library (the tool isn't offered). */ + conventions?: IConventionProvider; /** Editable scope — `edit`/`create` outside it are rejected. */ files: string[]; /** Optional edit guard: vetoes an applied edit (reverted on veto). Absent ⇒ no diff --git a/packages/core/src/loop/turn.ts b/packages/core/src/loop/turn.ts index fbab8b83..35bab404 100644 --- a/packages/core/src/loop/turn.ts +++ b/packages/core/src/loop/turn.ts @@ -25,7 +25,7 @@ import type { import { flags } from "../config"; import type { IStackProfile } from "../stack-detection"; import { gateFeedback } from "./feedback"; -import { unseenGuidesForErrors } from "./conventions"; +import type { IConventionProvider } from "./conventions-provider"; import { shouldCheckpoint, shouldRollback, @@ -280,6 +280,11 @@ export const BUILD_NUDGE = * spreads). Always-present and mutable: the Session flips these mid-run * (plan mode, per-send signal, setupWeb wiring). */ export interface ILoopCtxTool { + /** The build ADAPTER's convention library (injected seam). Spread into every + * IToolContext (so `pull_conventions` reads it) and read by the reactive PUSH + * (`injectFeedback`). Absent ⇒ no stack conventions. Keeps stack CONTENT out of + * the core loop. */ + conventions?: IConventionProvider; /** Cancellation for the in-flight turn — threaded into tool `run` commands and * the gate so a Ctrl-C (or a kill-timeout) reaches the child processes, not * just the model call. Set per-send by the Session. */ @@ -2171,8 +2176,8 @@ export async function injectFeedback( // plain build never gets boringstack-flavored guidance injected. state.pushedGuides ??= new Set(); const guides = - state.conventionsEnabled === true - ? unseenGuidesForErrors(gateErrors, state.pushedGuides) + state.conventionsEnabled === true && ctx.tool.conventions !== undefined + ? ctx.tool.conventions.unseenForErrors(gateErrors, state.pushedGuides) : []; const how = guides.length > 0 diff --git a/packages/core/tests/boringstack-conventions.test.ts b/packages/core/tests/boringstack-conventions.test.ts index 32242c7a..f292544b 100644 --- a/packages/core/tests/boringstack-conventions.test.ts +++ b/packages/core/tests/boringstack-conventions.test.ts @@ -6,6 +6,7 @@ import { isConventionTopic, topicForRule, unseenGuidesForErrors, + boringstackConventionProvider, } from "../src/loop/conventions"; import { PULL_CONVENTIONS_TOOL } from "../src/agent/agent.constants"; import { injectFeedback, type ILoopCtx } from "../src/loop/turn"; @@ -306,7 +307,9 @@ describe("convention PUSH delivery (the guide actually reaches the model + is ob events.push(e); }, messages: [], - tool: {}, + // The reactive PUSH reads its guides from the injected provider (ctx.tool.conventions) — + // the adapter sets this on the live build; the test supplies the same BoringStack provider. + tool: { conventions: boringstackConventionProvider }, gate: { parse: undefined, runner: commandGate( diff --git a/packages/core/tests/convention-index.test.ts b/packages/core/tests/convention-index.test.ts index e88947a7..6bd69316 100644 --- a/packages/core/tests/convention-index.test.ts +++ b/packages/core/tests/convention-index.test.ts @@ -11,8 +11,19 @@ import { } from "../src/loop/conventions"; import { PULL_CONVENTIONS_TOOL } from "../src/agent/agent.constants"; import type { IProvider, IChatMessage } from "../src/inference"; +import type { IConventionProvider } from "../src/loop/conventions-provider"; import { Session } from "../src/loop"; +/** A minimal fake convention provider returning fixed guide text (the rest of the seam + * surface is stubbed — these tests only exercise front-loading). */ +const fakeConventions = (guides: string): IConventionProvider => ({ + buildGuides: () => guides, + unseenForErrors: () => [], + guide: () => null, + topics: () => [], + isTopic: () => false, +}); + // WS-A1: front-load the actual convention GUIDES (the compliant patterns), not merely a // topic index — so the model writes it right the FIRST time (Bucket 1) instead of pulling // only reactively after the gate rejects it. @@ -132,7 +143,7 @@ test("the INJECTED provider's guide content reaches the prompt — not a static files: ["**/*"], executionMode: "drive-to-green", pullConventions: true, - conventions: { buildGuides: () => "FAKE_GUIDE_SENTINEL_9Z" }, + conventions: fakeConventions("FAKE_GUIDE_SENTINEL_9Z"), }); await s.send("go"); @@ -156,7 +167,7 @@ test("the pullConventions gate still governs — a provider WITHOUT the flag doe cwd: dir, files: ["**/*"], executionMode: "drive-to-green", - conventions: { buildGuides: () => "FAKE_GUIDE_SENTINEL_9Z" }, + conventions: fakeConventions("FAKE_GUIDE_SENTINEL_9Z"), }); await s.send("go"); diff --git a/packages/core/tests/pull-conventions.test.ts b/packages/core/tests/pull-conventions.test.ts index 01d83ee0..baa5e07f 100644 --- a/packages/core/tests/pull-conventions.test.ts +++ b/packages/core/tests/pull-conventions.test.ts @@ -1,22 +1,38 @@ import { test, expect, describe } from "bun:test"; import { doPullConventions } from "../src/loop/tools/pull-conventions"; +import { boringstackConventionProvider } from "../src/loop/conventions"; + +// The tool reads the convention library from the injected provider (ctx.conventions), so a real +// call supplies the BoringStack provider — the same one the adapter injects at runtime. +const ctx = { conventions: boringstackConventionProvider }; describe("pull_conventions tool", () => { test("returns the guide for a valid topic", () => { - expect(doPullConventions({ topic: "no-casts" })).toContain("TYPE GUARD"); - expect(doPullConventions({ topic: "component-anatomy" })).toContain( + expect(doPullConventions({ topic: "no-casts" }, ctx)).toContain( + "TYPE GUARD" + ); + expect(doPullConventions({ topic: "component-anatomy" }, ctx)).toContain( "src/features/" ); - expect(doPullConventions({ topic: "data-fetching" })).toContain( + expect(doPullConventions({ topic: "data-fetching" }, ctx)).toContain( "@/lib/api/client" ); }); test("an unknown/empty topic lists the valid ones (never a bare failure)", () => { - const r = doPullConventions({ topic: "styling" }); + const r = doPullConventions({ topic: "styling" }, ctx); expect(r).toContain("unknown topic"); expect(r).toContain("component-anatomy"); expect(r).toContain("no-casts"); }); + + test("no provider ⇒ a clear 'not configured' message, never a crash", () => { + const r = doPullConventions( + { topic: "no-casts" }, + { conventions: undefined } + ); + + expect(r).toContain("no convention library"); + }); }); From ad748be6baee884b3dde8cf54117600805ed0fc2 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 22:14:35 +0200 Subject: [PATCH 10/53] =?UTF-8?q?refactor(core):=20WS1b=20=E2=80=94=20drop?= =?UTF-8?q?=20dead=20isTopic,=20honest=20docblock,=20push-no-provider=20te?= =?UTF-8?q?st=20(panel=20BLOCK)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove IConventionProvider.isTopic (+ its impl) — no consumer (doPullConventions uses guide()+null), it was speculative surface (panel agreement-3 BLOCK). - Correct the boringstackConventionProvider docblock (WS1b done — no core file outside the adapter imports the content; only the file relocation remains). - Add the push-no-provider edge test: conventionsEnabled ON but ctx.tool.conventions absent ⇒ no push. Validate green (3234). --- packages/core/src/loop/conventions-provider.ts | 2 -- packages/core/src/loop/conventions.ts | 15 +++++++-------- .../core/tests/boringstack-conventions.test.ts | 16 ++++++++++++++++ packages/core/tests/convention-index.test.ts | 1 - 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/core/src/loop/conventions-provider.ts b/packages/core/src/loop/conventions-provider.ts index fa62adb0..d97009f9 100644 --- a/packages/core/src/loop/conventions-provider.ts +++ b/packages/core/src/loop/conventions-provider.ts @@ -21,6 +21,4 @@ export interface IConventionProvider { guide(topic: string): string | null; /** The valid topic ids (for the `pull_conventions` tool's help text). */ topics(): readonly string[]; - /** Whether a string is a known topic. */ - isTopic(s: string): boolean; } diff --git a/packages/core/src/loop/conventions.ts b/packages/core/src/loop/conventions.ts index da17a4de..06c63135 100644 --- a/packages/core/src/loop/conventions.ts +++ b/packages/core/src/loop/conventions.ts @@ -461,18 +461,17 @@ export function unseenGuidesForErrors( } /** - * The BoringStack front-loaded guides packaged as the generic `IConventionProvider` - * seam. The core session's system prompt depends on the INTERFACE (injected via - * `ISessionConfig.conventions`); this concrete provider — the BoringStack CONTENT — - * is supplied by the boringstack adapter (`build-config.ts`), so the session no longer - * imports `buildConventionGuides` directly. (WS1a scope: the reactive push + the - * `pull_conventions` tool still import this module directly — they migrate to the - * provider, and this module relocates into `loop/boringstack/`, in WS1b.) + * The BoringStack convention library packaged as the generic `IConventionProvider` + * seam. The core loop (system-prompt front-load, reactive push, `pull_conventions` + * tool) depends only on the INTERFACE — injected via `ISessionConfig.conventions` / + * `ILoopCtx.tool.conventions`; this concrete provider is the BoringStack CONTENT, + * supplied by the boringstack adapter (`build-config.ts`). No core file outside the + * adapter imports it. (Residual: this module still physically lives under `loop/`; + * relocating it into `loop/boringstack/` is the last step of WS1.) */ export const boringstackConventionProvider: IConventionProvider = { buildGuides: buildConventionGuides, unseenForErrors: unseenGuidesForErrors, guide: (topic) => (isConventionTopic(topic) ? conventionGuide(topic) : null), topics: conventionTopics, - isTopic: isConventionTopic, }; diff --git a/packages/core/tests/boringstack-conventions.test.ts b/packages/core/tests/boringstack-conventions.test.ts index f292544b..a79e35ca 100644 --- a/packages/core/tests/boringstack-conventions.test.ts +++ b/packages/core/tests/boringstack-conventions.test.ts @@ -404,4 +404,20 @@ describe("convention PUSH delivery (the guide actually reaches the model + is ob events.some((e) => e.kind === "tool" && e.message.includes("📐 pushed")) ).toBe(false); }); + + test("conventionsEnabled ON but NO provider injected ⇒ no push (WS1b provider gate)", async () => { + // The new push guard is `conventionsEnabled && ctx.tool.conventions !== undefined`. With the + // flag on but the provider absent (no adapter injected it), the push must be silent — proving + // the guides come from the injected provider, not a lingering static import. + const events: ILoopEvent[] = []; + const ctx = makeCtx(events); + + ctx.tool = {}; + + await injectFeedback(ctx, { ...freshState() }, [asCastError], [], []); + + expect(ctx.messages.at(-1)?.content ?? "").not.toContain( + "HOW TO WRITE THIS RIGHT" + ); + }); }); diff --git a/packages/core/tests/convention-index.test.ts b/packages/core/tests/convention-index.test.ts index 6bd69316..ea53e68c 100644 --- a/packages/core/tests/convention-index.test.ts +++ b/packages/core/tests/convention-index.test.ts @@ -21,7 +21,6 @@ const fakeConventions = (guides: string): IConventionProvider => ({ unseenForErrors: () => [], guide: () => null, topics: () => [], - isTopic: () => false, }); // WS-A1: front-load the actual convention GUIDES (the compliant patterns), not merely a From 6735a75b1c7d61aa88861847973f6bcf5e743ee0 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 22:26:24 +0200 Subject: [PATCH 11/53] docs(core): correct IToolContext.conventions JSDoc (drop removed isTopic; note not-configured message) --- packages/core/src/loop/tools/tool-context.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/loop/tools/tool-context.ts b/packages/core/src/loop/tools/tool-context.ts index 3eb36928..735c58e4 100644 --- a/packages/core/src/loop/tools/tool-context.ts +++ b/packages/core/src/loop/tools/tool-context.ts @@ -95,8 +95,8 @@ export function composeGuards(...guards: readonly EditGuard[]): EditGuard { export interface IToolContext { cwd: string; /** The build ADAPTER's convention library (injected seam) — the `pull_conventions` - * tool reads its `guide`/`topics`/`isTopic` from here instead of importing stack - * content. Absent ⇒ no convention library (the tool isn't offered). */ + * tool reads its `guide`/`topics` from here instead of importing stack content. + * Absent ⇒ `pull_conventions` returns a "not configured" message. */ conventions?: IConventionProvider; /** Editable scope — `edit`/`create` outside it are rejected. */ files: string[]; From fb5e29fcabb6ff7900ca050b0b74f768e1046ebf Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 22:53:26 +0200 Subject: [PATCH 12/53] =?UTF-8?q?test(core):=20WS1b=20=E2=80=94=20prove=20?= =?UTF-8?q?Session=20threads=20cfg.conventions=20into=20the=20reactive=20p?= =?UTF-8?q?ush=20(real=20drive=20loop)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the panel's session-wiring gap: a real drive-to-green Session where the model makes an edit, the RED gate fires, and the reactive push injects the INJECTED provider's sentinel guide into the next message. Deleting session.ts's ctx.tool.conventions spread makes the push find no provider ⇒ the sentinel never appears ⇒ this fails. (The tool-path version is blocked by a pre-existing bug — task #95: pull_conventions is missing from the policy classifier, so it's denied 'unrecognized' in non-interactive builds.) Validate green (3235). --- packages/core/tests/convention-index.test.ts | 102 +++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/packages/core/tests/convention-index.test.ts b/packages/core/tests/convention-index.test.ts index ea53e68c..ce111960 100644 --- a/packages/core/tests/convention-index.test.ts +++ b/packages/core/tests/convention-index.test.ts @@ -12,8 +12,28 @@ import { import { PULL_CONVENTIONS_TOOL } from "../src/agent/agent.constants"; import type { IProvider, IChatMessage } from "../src/inference"; import type { IConventionProvider } from "../src/loop/conventions-provider"; +import type { IGate } from "../src/gate/gate-runner"; +import type { IValidateResult } from "../src/validate"; import { Session } from "../src/loop"; +/** A gate that always reports one error, so the drive loop hits a failure after the model's edit + * and fires the reactive convention PUSH. */ +const redGate: IGate = { + run: async (): Promise => ({ + passed: false, + errors: [ + { + key: "a", + file: "a.ts", + line: 1, + rule: "no-casts", + message: "no as", + }, + ], + output: "", + }), +}; + /** A minimal fake convention provider returning fixed guide text (the rest of the seam * surface is stubbed — these tests only exercise front-loading). */ const fakeConventions = (guides: string): IConventionProvider => ({ @@ -177,6 +197,88 @@ test("the pullConventions gate still governs — a provider WITHOUT the flag doe } }); +test("Session threads cfg.conventions to the reactive PUSH (real drive loop)", async () => { + // The Session-wiring proof: a real drive-to-green session — the model makes an edit, the RED gate + // fires, and the reactive push must inject the INJECTED provider's guide (a sentinel) into the + // next message the model sees. Deleting session.ts's `ctx.tool.conventions = cfg.conventions` + // spread makes the push find no provider ⇒ the sentinel never appears ⇒ this fails. + const dir = await mkdtemp(join(tmpdir(), "tsforge-conv-push-")); + + await Bun.write(join(dir, "a.ts"), "export const x = 1;\n"); + + const seen = { sentinel: false }; + let turn = 0; + + const provider: IProvider = { + async complete(messages: IChatMessage[]) { + turn += 1; + + if ( + messages.some( + (m) => + typeof m.content === "string" && + m.content.includes("PUSH_THREAD_SENTINEL") + ) + ) { + seen.sentinel = true; + } + + if (turn === 1) { + // An in-scope edit so the drive loop runs the gate (→ RED → reactive push next turn). + return { + content: "", + toolCalls: [ + { + id: "1", + name: "edit", + arguments: { + file: "a.ts", + oldString: "const x = 1;", + newString: "const x = 2;", + }, + }, + ], + }; + } + + return { content: "done", toolCalls: [] }; + }, + }; + + const fakeConv: IConventionProvider = { + buildGuides: () => "", + unseenForErrors: (errors, seenSet) => { + if (errors.length === 0 || seenSet.has("x")) { + return []; + } + + seenSet.add("x"); + + return ["PUSH_THREAD_SENTINEL"]; + }, + guide: () => null, + topics: () => [], + }; + + try { + const s = await Session.create({ + provider, + cwd: dir, + files: ["**/*"], + executionMode: "drive-to-green", + pullConventions: true, + conventions: fakeConv, + gate: redGate, + }); + + await s.send("build it"); + + expect(seen.sentinel).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + // The pull_conventions tool enum is a hand-maintained duplicate of TOPICS — this locks it to // conventionTopics() so a new topic (or a dropped one, like data-fetching was) can't silently // diverge, leaving a guide the model can't actually pull. From 67f60b1e350fcd688c7fedb433585bae3139661d Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 23:04:43 +0200 Subject: [PATCH 13/53] fix(policy)+test(core): classify pull_conventions as read_file; prove PULL threading end-to-end (WS1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the panel finding that the migrated PULL half was nonfunctional + untestable: - policy/classify.ts: add [TOOL_NAME.pullConventions]: 'read_file' — the pure read-only lookup was missing from the classifier, so a model's pull_conventions call classified 'unknown' → policy DENY in non-interactive builds (same DOA class as the historical 'script' bug). Now allowed everywhere. - Real-Session test: the model calls pull_conventions and gets back the INJECTED provider's guide (sentinel) through the real dispatcher — proves cfg.conventions → ctx.tool.conventions → doPullConventions. - Corrected the ISessionConfig.conventions JSDoc (all three paths now draw from the provider). WS1b now: no core file outside the adapter imports convention content; push+pull+front-load all injected + tested end-to-end. Validate green (3236). --- packages/core/src/loop/session.ts | 8 +-- packages/core/src/policy/classify.ts | 5 ++ packages/core/tests/convention-index.test.ts | 55 ++++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/core/src/loop/session.ts b/packages/core/src/loop/session.ts index 60429d97..a579d8d3 100644 --- a/packages/core/src/loop/session.ts +++ b/packages/core/src/loop/session.ts @@ -177,10 +177,10 @@ export interface ISessionConfig { * patterns on demand. Decoupled from any flag: a plain session leaves it off. */ pullConventions?: boolean; /** The build ADAPTER's convention library, injected as a generic provider (see - * `IConventionProvider`), so the core no longer imports stack-specific CONTENT for - * the system prompt. WS1a: the FRONT-LOADED guides come from here (gated, with - * `pullConventions`, on a backend that ships a library). The reactive push + the - * `pull_conventions` tool still read the library directly until WS1b migrates them. */ + * `IConventionProvider`), so the core no longer imports stack-specific CONTENT. ALL + * three delivery paths draw from it: the FRONT-LOADED guides in the system prompt + * (gated with `pullConventions`), the reactive PUSH, and the `pull_conventions` tool + * (both via `ILoopCtx.tool.conventions`). Absent ⇒ no stack conventions. */ conventions?: IConventionProvider; /** Offer the callable, structured `check` tool (WS-G) — set by a build BACKEND * whose gate is authoritative (e.g. boringstack, which injects its gate per-slice diff --git a/packages/core/src/policy/classify.ts b/packages/core/src/policy/classify.ts index eebd8c25..b5beecbc 100644 --- a/packages/core/src/policy/classify.ts +++ b/packages/core/src/policy/classify.ts @@ -35,6 +35,11 @@ const KIND_BY_TOOL: Readonly> = { // classified `read_file` so it's allowed in every mode (incl. plan). Absent here it // would classify `unknown` → deny before the handler runs (the check/script DOA class). [TOOL_NAME.askUser]: "read_file", + // `pull_conventions` is a pure read-only lookup of the injected convention library — it mutates + // nothing, so it classifies `read_file` (allowed in every mode). Absent here it classified + // `unknown` → deny before the handler ran, so a model's pull was silently denied in non-interactive + // builds (the same check/ask_user DOA class). + [TOOL_NAME.pullConventions]: "read_file", // Delegating to a read-only subagent — its own class so a repo can deny/ask it // specifically; the child's tool calls are re-classified as they dispatch. [TOOL_NAME.spawnAgent]: "spawn_agent", diff --git a/packages/core/tests/convention-index.test.ts b/packages/core/tests/convention-index.test.ts index ce111960..96c5b6f4 100644 --- a/packages/core/tests/convention-index.test.ts +++ b/packages/core/tests/convention-index.test.ts @@ -279,6 +279,61 @@ test("Session threads cfg.conventions to the reactive PUSH (real drive loop)", a } }); +test("Session threads cfg.conventions into the pull_conventions tool (real dispatch)", async () => { + // The PULL half through the real dispatcher: the model calls pull_conventions and gets back the + // INJECTED provider's guide (a sentinel), proving cfg.conventions → ctx.tool.conventions → + // doPullConventions. Requires pull_conventions to survive the policy layer (classify.ts). + const dir = await mkdtemp(join(tmpdir(), "tsforge-conv-pull-")); + const captured: { result: string | null } = { result: null }; + let turn = 0; + + const provider: IProvider = { + async complete(messages: IChatMessage[]) { + turn += 1; + + if (turn === 1) { + return { + content: "", + toolCalls: [ + { id: "1", name: "pull_conventions", arguments: { topic: "x" } }, + ], + }; + } + + const toolMsg = [...messages].reverse().find((m) => m.role === "tool"); + + captured.result = + typeof toolMsg?.content === "string" ? toolMsg.content : null; + + return { content: "done", toolCalls: [] }; + }, + }; + + const fakeConv: IConventionProvider = { + buildGuides: () => "", + unseenForErrors: () => [], + guide: () => "TOOL_THREAD_SENTINEL", + topics: () => ["x"], + }; + + try { + const s = await Session.create({ + provider, + cwd: dir, + files: ["**/*"], + executionMode: "drive-to-green", + pullConventions: true, + conventions: fakeConv, + }); + + await s.send("go"); + + expect(captured.result).toContain("TOOL_THREAD_SENTINEL"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + // The pull_conventions tool enum is a hand-maintained duplicate of TOPICS — this locks it to // conventionTopics() so a new topic (or a dropped one, like data-fetching was) can't silently // diverge, leaving a guide the model can't actually pull. From 36a83d14363e6ca65a3c1291683e89e19b612da4 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 23:16:01 +0200 Subject: [PATCH 14/53] =?UTF-8?q?fix(core)+test:=20close=20WS1b=20r4=20fin?= =?UTF-8?q?dings=20=E2=80=94=20withheld-capability=20gate=20+=20classifier?= =?UTF-8?q?=20unit-lock=20+=20provider=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session.ts: gate ctx.tool.conventions on pullConventions (not just provider presence). Now that pull_conventions is policy-allowed (read_file), a hallucinated call with the feature OFF must find no provider ⇒ 'not configured', never a withheld capability that executes with the real library. + test proving it. - policy-evaluation.test.ts: pin pull_conventions → read_file in the canonical KIND_BY_TOOL cases table (the unit lock, alongside check/ask_user). - conventions-provider.test.ts: assert guide/topics/unseenForErrors return real content + dedupe, not just buildGuides. Validate green (3238). --- packages/core/src/loop/session.ts | 11 ++-- packages/core/tests/convention-index.test.ts | 54 +++++++++++++++++++ .../core/tests/conventions-provider.test.ts | 24 +++++++++ packages/core/tests/policy-evaluation.test.ts | 3 ++ 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/packages/core/src/loop/session.ts b/packages/core/src/loop/session.ts index a579d8d3..6f31c5d3 100644 --- a/packages/core/src/loop/session.ts +++ b/packages/core/src/loop/session.ts @@ -952,10 +952,13 @@ export class Session { // path) and co-pilot presence must not loosen policy verdicts. ...(cfg.interactive === true ? { humanPresent: true } : {}), // The adapter's convention library — spread into every IToolContext (so - // `pull_conventions` reads its guides) and read by the reactive push. - ...(cfg.conventions === undefined - ? {} - : { conventions: cfg.conventions }), + // `pull_conventions` reads its guides) and read by the reactive push. Gated on + // `pullConventions` (the same flag that OFFERS the tool + enables the push), so a + // hallucinated pull_conventions call when the feature is off finds no provider + // (returns "not configured"), never a withheld-capability that still executes. + ...(cfg.pullConventions === true && cfg.conventions !== undefined + ? { conventions: cfg.conventions } + : {}), }, gate: { parse: cfg.parse, diff --git a/packages/core/tests/convention-index.test.ts b/packages/core/tests/convention-index.test.ts index 96c5b6f4..0326b792 100644 --- a/packages/core/tests/convention-index.test.ts +++ b/packages/core/tests/convention-index.test.ts @@ -334,6 +334,60 @@ test("Session threads cfg.conventions into the pull_conventions tool (real dispa } }); +test("a hallucinated pull_conventions call with pullConventions OFF gets no provider (no withheld-capability leak)", async () => { + // The provider is threaded into the tool context ONLY when pullConventions is on (the flag that + // offers the tool). With the flag off, a hallucinated pull_conventions call executes (read-only, + // policy-allowed) but finds no provider → "not configured", never the injected guides. + const dir = await mkdtemp(join(tmpdir(), "tsforge-conv-withheld-")); + const captured: { result: string | null } = { result: null }; + let turn = 0; + + const provider: IProvider = { + async complete(messages: IChatMessage[]) { + turn += 1; + + if (turn === 1) { + return { + content: "", + toolCalls: [ + { id: "1", name: "pull_conventions", arguments: { topic: "x" } }, + ], + }; + } + + const toolMsg = [...messages].reverse().find((m) => m.role === "tool"); + + captured.result = + typeof toolMsg?.content === "string" ? toolMsg.content : null; + + return { content: "done", toolCalls: [] }; + }, + }; + + try { + const s = await Session.create({ + provider, + cwd: dir, + files: ["**/*"], + executionMode: "drive-to-green", + // pullConventions OFF — but a provider is still on the config. + conventions: { + buildGuides: () => "", + unseenForErrors: () => [], + guide: () => "TOOL_THREAD_SENTINEL", + topics: () => ["x"], + }, + }); + + await s.send("go"); + + expect(captured.result ?? "").not.toContain("TOOL_THREAD_SENTINEL"); + expect(captured.result ?? "").toContain("no convention library"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + // The pull_conventions tool enum is a hand-maintained duplicate of TOPICS — this locks it to // conventionTopics() so a new topic (or a dropped one, like data-fetching was) can't silently // diverge, leaving a guide the model can't actually pull. diff --git a/packages/core/tests/conventions-provider.test.ts b/packages/core/tests/conventions-provider.test.ts index 54adbf01..9d9ecdb8 100644 --- a/packages/core/tests/conventions-provider.test.ts +++ b/packages/core/tests/conventions-provider.test.ts @@ -13,3 +13,27 @@ test("boringstackConventionProvider satisfies IConventionProvider with real guid expect(guides.length).toBeGreaterThan(0); expect(guides).toContain("HOW THIS STACK WRITES CODE"); }); + +test("the provider's guide/topics/unseenForErrors return real content (not stubs)", () => { + const provider: IConventionProvider = boringstackConventionProvider; + + // topics() lists real topics; guide() returns the pattern for one of them. + const topics = provider.topics(); + + expect(topics).toContain("no-casts"); + expect(provider.guide("no-casts")).toContain("TYPE GUARD"); + expect(provider.guide("nope-not-a-topic")).toBeNull(); + + // unseenForErrors() maps a gate error's rule to its guide, deduping via `seen`. + const seen = new Set(); + const first = provider.unseenForErrors( + [{ rule: "no-restricted-syntax" }], + seen + ); + + expect(first.length).toBeGreaterThan(0); + // Same rule again ⇒ deduped (already seen this run). + expect( + provider.unseenForErrors([{ rule: "no-restricted-syntax" }], seen) + ).toEqual([]); +}); diff --git a/packages/core/tests/policy-evaluation.test.ts b/packages/core/tests/policy-evaluation.test.ts index d62b056b..7a3577b2 100644 --- a/packages/core/tests/policy-evaluation.test.ts +++ b/packages/core/tests/policy-evaluation.test.ts @@ -52,6 +52,9 @@ describe("classifyAction", () => { ["check", "shell"], // WS-C1: ask_user mutates nothing — read_file (zero-risk), NOT unknown. ["ask_user", "read_file"], + // pull_conventions is a pure read-only convention lookup — read_file, NOT unknown + // (absent ⇒ non-interactive deny, the check/ask_user DOA class). + ["pull_conventions", "read_file"], ["package_info", "network"], ["package_docs", "network"], ["web_fetch", "network"], From 22a2b64d82ee13a98e03d4036f7f06f57d6e89e1 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 23:37:04 +0200 Subject: [PATCH 15/53] refactor(core): drop hardcoded topic enum from pull_conventions tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS1-tail: PULL_CONVENTIONS_TOOL.topic is now a plain string described by the front-loaded guides, not a hand-maintained BoringStack topic enum baked into core. Topics come from the injected provider at runtime; validity is enforced by the provider's guide()/topics() (unknown topic → listing). Repoint the three enum-sync tests to registry membership (the enum they guarded is gone). Removes the last BoringStack-topic literal from core's agent tool schema. --- packages/core/src/agent/agent.constants.ts | 30 ++++++------------- .../tests/boringstack-conventions.test.ts | 15 ++++------ packages/core/tests/convention-index.test.ts | 30 +++++-------------- 3 files changed, 21 insertions(+), 54 deletions(-) diff --git a/packages/core/src/agent/agent.constants.ts b/packages/core/src/agent/agent.constants.ts index 27c42e17..26e0805f 100644 --- a/packages/core/src/agent/agent.constants.ts +++ b/packages/core/src/agent/agent.constants.ts @@ -345,38 +345,26 @@ export const PACKAGE_INFO_TOOL = { }, }; +/** + * The `pull_conventions` tool schema. STACK-AGNOSTIC by design: the valid topics come from + * the injected convention provider at RUNTIME (the tool validates the topic and lists the + * valid ones on a miss), so core carries no adapter-specific topic list — `topic` is a plain + * string, not a hardcoded enum. The available topics are enumerated in the front-loaded guides + * already in the model's system prompt. + */ export const PULL_CONVENTIONS_TOOL = { type: "function", function: { name: TOOL_NAME.pullConventions, description: - "Re-fetch the boringstack HOW-TO guide for a topic on demand. The core guides are ALREADY front-loaded in your system prompt — use this to re-read one you need again, or for a rule you're still unsure how to satisfy. Returns the exact pattern the gate enforces.", + "Re-fetch the stack's HOW-TO guide for a convention topic on demand. The guides are ALREADY front-loaded in your system prompt — use this to re-read one you need again, or for a rule you're still unsure how to satisfy. Returns the exact pattern the gate enforces.", parameters: { type: "object", properties: { topic: { type: "string", - enum: [ - "component-anatomy", - "file-layout", - "jsx", - "state", - "no-casts", - "routing", - "forms", - "data-fetching", - "lint-gotchas", - "testing", - "api-service", - "i18n", - "design-tokens", - "theming", - "responsive", - "accessibility", - "components-ui", - ], description: - 'which guide: component-anatomy (where a component lives + one-per-file), file-layout (no inline types/constants/helpers), jsx (no computation in markup), state (hooks, not component body), no-casts (type guards instead of `as`/`!`), routing (thin route files), forms, data-fetching (api-client, never raw fetch), lint-gotchas (await promises, no void-expr values, no stringified errors, no duplicate strings), testing (.test.ts vs .test.tsx, the vi.hoisted api-client mock, createApp/app.handle route tests, enforced test rules), api-service (mutating service methods record an audit event; throw ApiError), i18n (add a locale key only when you reference it via t("key") — never pre-declare, or it\'s a dead-key error), design-tokens (never hardcode colors; use CSS-variable Tailwind tokens by role), theming (data-theme-driven, never dark: variants), responsive (mobile-first breakpoints + Sheet mobile drawer), accessibility (satisfy jsx-a11y up front: aria-label/aria-hidden/sr-only, no interactive div, semantic landmarks), components-ui (use @/components/ui Radix primitives, cn() + cva, asChild).', + "which convention guide to fetch — one of the topics listed in the front-loaded guides in your system prompt. An unknown topic returns the list of valid ones.", }, }, required: ["topic"], diff --git a/packages/core/tests/boringstack-conventions.test.ts b/packages/core/tests/boringstack-conventions.test.ts index a79e35ca..114267b7 100644 --- a/packages/core/tests/boringstack-conventions.test.ts +++ b/packages/core/tests/boringstack-conventions.test.ts @@ -8,7 +8,6 @@ import { unseenGuidesForErrors, boringstackConventionProvider, } from "../src/loop/conventions"; -import { PULL_CONVENTIONS_TOOL } from "../src/agent/agent.constants"; import { injectFeedback, type ILoopCtx } from "../src/loop/turn"; import type { ILoopState, ILoopEvent } from "../src/loop"; import type { IErrorItem } from "../src/validate"; @@ -95,17 +94,13 @@ describe("convention registry", () => { expect(g).toContain("BYPASSES validation"); }); - test("i18n is a first-class pullable topic — registry AND pull_conventions enum agree (no drift)", () => { - // The three hand-maintained surfaces for a topic must all carry `i18n`, or the model - // hits a live guide it can't `pull_conventions` for (enum reject) — the exact drift the - // reviewers flagged. convention-index.test.ts locks the full enum↔topics parity; this pins - // the NEW topic explicitly in its own change so the guard is visible beside the addition. + test("i18n is a first-class pullable topic — present in the runtime registry", () => { + // `i18n` must be in the topic registry, or the model hits a live guide it can't + // `pull_conventions` for. The pull tool no longer carries a hardcoded enum (topics come + // from the injected provider at runtime), so registry membership is the single surface + // that makes a topic pullable — this pins the NEW topic beside its addition. expect(conventionTopics()).toContain("i18n"); expect(isConventionTopic("i18n")).toBe(true); - const enumTopics = - PULL_CONVENTIONS_TOOL.function.parameters.properties.topic.enum; - - expect(enumTopics).toContain("i18n"); }); test("forms guide steers away from the invented FormEvent + deprecated z.string().email() (live-build residuals)", () => { diff --git a/packages/core/tests/convention-index.test.ts b/packages/core/tests/convention-index.test.ts index 0326b792..7daa122d 100644 --- a/packages/core/tests/convention-index.test.ts +++ b/packages/core/tests/convention-index.test.ts @@ -9,7 +9,6 @@ import { topicForRule, boringstackConventionProvider, } from "../src/loop/conventions"; -import { PULL_CONVENTIONS_TOOL } from "../src/agent/agent.constants"; import type { IProvider, IChatMessage } from "../src/inference"; import type { IConventionProvider } from "../src/loop/conventions-provider"; import type { IGate } from "../src/gate/gate-runner"; @@ -388,23 +387,12 @@ test("a hallucinated pull_conventions call with pullConventions OFF gets no prov } }); -// The pull_conventions tool enum is a hand-maintained duplicate of TOPICS — this locks it to -// conventionTopics() so a new topic (or a dropped one, like data-fetching was) can't silently -// diverge, leaving a guide the model can't actually pull. -test("PULL_CONVENTIONS_TOOL enum stays in sync with conventionTopics()", () => { - const enumTopics = - PULL_CONVENTIONS_TOOL.function.parameters.properties.topic.enum; - - expect([...enumTopics].sort()).toEqual([...conventionTopics()].sort()); -}); - -// Explicit, diff-visible guard for the 5 design-system topics this change added: each must be -// pullable — present in BOTH the runtime guide registry and the hand-maintained pull-tool enum — -// so validate can't stay green while the tool schema and registry diverge for these topics. -test("the new design-system topics are in both the guide registry and the pull enum", () => { - const enumTopics = - PULL_CONVENTIONS_TOOL.function.parameters.properties.topic.enum; - const registry = conventionTopics(); +// The pull_conventions tool no longer carries a hardcoded topic enum (topics come from the +// injected provider at runtime, listed in the front-loaded guides) — so the old enum↔registry +// duplicate is gone. What still matters: every design-system topic the guides added is present +// in the runtime registry, so it's actually PULLABLE via the provider. +test("the design-system topics are in the convention registry (pullable)", () => { + const registry = new Set(conventionTopics()); for (const topic of [ "design-tokens", @@ -413,11 +401,7 @@ test("the new design-system topics are in both the guide registry and the pull e "accessibility", "components-ui", ]) { - // A Set membership test compares the string cleanly against the typed - // ConventionTopic[] / enum tuple — no cast, and eslint won't rewrite it into a - // type-narrowing `.includes()` (which fails typecheck: string vs the topic union). - expect(new Set(registry).has(topic)).toBe(true); - expect(new Set(enumTopics).has(topic)).toBe(true); + expect(registry.has(topic)).toBe(true); } }); From bc9a0f1e64483d4427972eb1dd1c7407fd2d76f4 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 31 Jul 2026 23:49:13 +0200 Subject: [PATCH 16/53] refactor(core): build pull_conventions topic enum from injected provider topics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel r1 (WS1-tail) BLOCK: dropping to a free-form topic string was coarser than necessary and left the schema untested. Replace the removed hardcoded enum with buildPullConventionsTool(topics) — the enum-at-offer pattern buildSpawnAgentTool already uses for subagent_type. The topic enum is now built from the injected provider's topics() at offer time: core carries NO topic literal (stack-agnostic), the model still gets structured guidance (no wasted turn on an invalid topic), and an empty provider degrades to a free-form string. session threads cfg.conventions.topics() through toolsFor, gated on pullConventions to match the capability-bypass guard. Tests: enum mirrors injected topics exactly (arbitrary + real), empty-provider free-form fallback, and the offer path (toolsFor) publishes the topics in the advertised schema. Replaces the obsolete enum-sync assertions. --- packages/core/src/agent/agent.constants.ts | 68 +++++++++++++++----- packages/core/src/loop/session.ts | 12 +++- packages/core/src/loop/turn.ts | 9 +-- packages/core/tests/convention-index.test.ts | 50 ++++++++++++++ 4 files changed, 119 insertions(+), 20 deletions(-) diff --git a/packages/core/src/agent/agent.constants.ts b/packages/core/src/agent/agent.constants.ts index 26e0805f..13e15224 100644 --- a/packages/core/src/agent/agent.constants.ts +++ b/packages/core/src/agent/agent.constants.ts @@ -352,25 +352,63 @@ export const PACKAGE_INFO_TOOL = { * string, not a hardcoded enum. The available topics are enumerated in the front-loaded guides * already in the model's system prompt. */ -export const PULL_CONVENTIONS_TOOL = { - type: "function", - function: { - name: TOOL_NAME.pullConventions, - description: - "Re-fetch the stack's HOW-TO guide for a convention topic on demand. The guides are ALREADY front-loaded in your system prompt — use this to re-read one you need again, or for a rule you're still unsure how to satisfy. Returns the exact pattern the gate enforces.", - parameters: { - type: "object", - properties: { - topic: { - type: "string", +/** + * The pull_conventions tool, built per-offer so its `topic` enum carries the + * injected convention provider's REAL topics — the same enum-at-offer pattern + * `buildSpawnAgentTool` uses for `subagent_type`. Core stays stack-agnostic: the + * topic list comes from the adapter's provider (`IConventionProvider.topics()`) + * at offer time, never a hardcoded literal here. With an enum the model gets + * structured guidance and can't waste a turn on an invalid topic; the handler's + * miss→listing recovery still guards a hallucinated topic. With no topics (an + * empty provider) the enum is omitted so the tool stays usable as a free-form + * lookup. + */ +export function buildPullConventionsTool(topics: readonly string[]): { + readonly type: "function"; + readonly function: { + readonly name: typeof TOOL_NAME.pullConventions; + readonly description: string; + readonly parameters: { + readonly type: "object"; + readonly properties: { + readonly topic: { + readonly type: "string"; + readonly description: string; + readonly enum?: readonly string[]; + }; + }; + readonly required: readonly ["topic"]; + }; + }; +} { + const topic = + topics.length > 0 + ? { + type: "string" as const, + description: + "which convention guide to fetch — one of the enumerated topics.", + enum: topics, + } + : { + type: "string" as const, description: "which convention guide to fetch — one of the topics listed in the front-loaded guides in your system prompt. An unknown topic returns the list of valid ones.", - }, + }; + + return { + type: "function", + function: { + name: TOOL_NAME.pullConventions, + description: + "Re-fetch the stack's HOW-TO guide for a convention topic on demand. The guides are ALREADY front-loaded in your system prompt — use this to re-read one you need again, or for a rule you're still unsure how to satisfy. Returns the exact pattern the gate enforces.", + parameters: { + type: "object", + properties: { topic }, + required: ["topic"], }, - required: ["topic"], }, - }, -} as const; + }; +} export const PACKAGE_DOCS_TOOL = { type: "function", diff --git a/packages/core/src/loop/session.ts b/packages/core/src/loop/session.ts index 6f31c5d3..88d4a39d 100644 --- a/packages/core/src/loop/session.ts +++ b/packages/core/src/loop/session.ts @@ -800,12 +800,22 @@ export class Session { const offerCheck = cfg.offerCheck === true && cfg.executionMode === "drive-to-green"; + // The pull_conventions topic enum is built from the injected provider's real + // topics at offer time (stack-agnostic — core carries no topic literal). Gated + // on pullConventions to match the capability-bypass guard below: no capability, + // no topics. + const conventionTopics = + cfg.pullConventions === true && cfg.conventions !== undefined + ? cfg.conventions.topics() + : []; + this.tools = toolsFor( false, {}, cfg.pullConventions === true, offerCheck, - cfg.interactive === true + cfg.interactive === true, + conventionTopics ); this.ctx = ctx; diff --git a/packages/core/src/loop/turn.ts b/packages/core/src/loop/turn.ts index 35bab404..91ebc3eb 100644 --- a/packages/core/src/loop/turn.ts +++ b/packages/core/src/loop/turn.ts @@ -83,7 +83,7 @@ import { WEB_BROWSE_TOOL, PACKAGE_INFO_TOOL, PACKAGE_DOCS_TOOL, - PULL_CONVENTIONS_TOOL, + buildPullConventionsTool, SCRIPT_TOOL, GIT_CONTEXT_TOOL, READ_IMAGE_TOOL, @@ -140,7 +140,7 @@ type AdvertisedTool = | typeof WEB_BROWSE_TOOL | typeof PACKAGE_INFO_TOOL | typeof PACKAGE_DOCS_TOOL - | typeof PULL_CONVENTIONS_TOOL + | ReturnType | typeof SCRIPT_TOOL | typeof GIT_CONTEXT_TOOL | typeof READ_IMAGE_TOOL @@ -200,7 +200,8 @@ export function toolsFor( caps: ICapabilityFlags = {}, offerConventions = false, offerCheck = false, - offerAskUser = false + offerAskUser = false, + conventionTopics: readonly string[] = [] ): AdvertisedTool[] { const web = webTools(); const git = gitTools(hasExistingCode); @@ -227,7 +228,7 @@ export function toolsFor( // minimal (tools-gating). Decoupled from the web flag on purpose — the conventions // are the stack's, not "web". const conventions: AdvertisedTool[] = offerConventions - ? [PULL_CONVENTIONS_TOOL] + ? [buildPullConventionsTool(conventionTopics)] : []; if (flags.noLspTools() || !hasExistingCode) { diff --git a/packages/core/tests/convention-index.test.ts b/packages/core/tests/convention-index.test.ts index 7daa122d..ea0c2b6f 100644 --- a/packages/core/tests/convention-index.test.ts +++ b/packages/core/tests/convention-index.test.ts @@ -14,6 +14,8 @@ import type { IConventionProvider } from "../src/loop/conventions-provider"; import type { IGate } from "../src/gate/gate-runner"; import type { IValidateResult } from "../src/validate"; import { Session } from "../src/loop"; +import { toolsFor } from "../src/loop/turn"; +import { buildPullConventionsTool, TOOL_NAME } from "../src/agent"; /** A gate that always reports one error, so the drive loop hits a failure after the model's edit * and fires the reactive convention PUSH. */ @@ -405,6 +407,54 @@ test("the design-system topics are in the convention registry (pullable)", () => } }); +// The pull_conventions topic enum is now built AT OFFER TIME from whatever topics are passed — +// it is NOT a literal baked into core. These pin that contract: the enum mirrors the injected +// topics exactly (any list, not a hardcoded BoringStack one), and an empty provider degrades to +// a free-form string rather than an empty/illegal enum. +test("buildPullConventionsTool builds the topic enum from the injected topics (no core literal)", () => { + // Arbitrary topics — proves the enum is whatever you inject, so core carries no topic literal. + const arbitrary = buildPullConventionsTool(["alpha", "beta"]); + + expect(arbitrary.function.name).toBe(TOOL_NAME.pullConventions); + expect(arbitrary.function.parameters.properties.topic.enum).toEqual([ + "alpha", + "beta", + ]); + + // With the real BoringStack topics, the enum equals the provider's registry — the only place + // the concrete topic list lives is the adapter's provider, not this tool. + const real = buildPullConventionsTool(boringstackConventionProvider.topics()); + + expect(real.function.parameters.properties.topic.enum).toEqual([ + ...boringstackConventionProvider.topics(), + ]); +}); + +test("buildPullConventionsTool omits the enum for an empty provider (free-form fallback)", () => { + const tool = buildPullConventionsTool([]); + const { topic } = tool.function.parameters.properties; + + expect(topic.type).toBe("string"); + expect(topic.enum).toBeUndefined(); +}); + +// The offer path (session → toolsFor) must PUBLISH the provider's topics in the advertised tool +// schema — the model sees exactly the pullable topics, and only when the capability is offered. +test("toolsFor publishes the injected topics as the pull_conventions enum when conventions are offered", () => { + const offered = toolsFor(false, {}, true, false, false, ["x", "y"]); + + // The advertised tool is byte-equal to building it directly with those topics — the offer + // path threaded the injected topics straight into the published schema. + expect(offered).toContainEqual(buildPullConventionsTool(["x", "y"])); + + // Capability off ⇒ the tool isn't advertised at all (no topic surface to leak). + const withheld = toolsFor(false, {}, false, false, false, ["x", "y"]); + + expect( + withheld.find((t) => t.function.name === TOOL_NAME.pullConventions) + ).toBeUndefined(); +}); + // The STATE guide must NOT tell the model to use raw fetch (it contradicts DATA-FETCHING's // fetch ban) — the aggregate front-load test can't catch this because the data-fetching guide // separately contains the api-client string. From f1da038caa3a05ca92cd6d7ca395c7e561e09e08 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 00:05:23 +0200 Subject: [PATCH 17/53] test(core): observe Session-advertised pull_conventions schema + drop orphaned JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel r2 (WS1-tail) BLOCK: two findings. - major/missing-test: no test observed the schema Session actually advertises, so a regression swapping cfg.conventions.topics() for [] would stay green. Add a Session test that intercepts provider.complete's tools and asserts the advertised set contains buildPullConventionsTool(provider.topics()) — the end-to-end guard for the offer-time enum threading. - minor/dead-code: remove the orphaned JSDoc block left above buildPullConventionsTool (the second block is the one that attaches). validate green (3241 pass, 0 fail). --- packages/core/src/agent/agent.constants.ts | 7 ---- packages/core/tests/convention-index.test.ts | 44 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/core/src/agent/agent.constants.ts b/packages/core/src/agent/agent.constants.ts index 13e15224..781c2641 100644 --- a/packages/core/src/agent/agent.constants.ts +++ b/packages/core/src/agent/agent.constants.ts @@ -345,13 +345,6 @@ export const PACKAGE_INFO_TOOL = { }, }; -/** - * The `pull_conventions` tool schema. STACK-AGNOSTIC by design: the valid topics come from - * the injected convention provider at RUNTIME (the tool validates the topic and lists the - * valid ones on a miss), so core carries no adapter-specific topic list — `topic` is a plain - * string, not a hardcoded enum. The available topics are enumerated in the front-loaded guides - * already in the model's system prompt. - */ /** * The pull_conventions tool, built per-offer so its `topic` enum carries the * injected convention provider's REAL topics — the same enum-at-offer pattern diff --git a/packages/core/tests/convention-index.test.ts b/packages/core/tests/convention-index.test.ts index ea0c2b6f..70b53d2a 100644 --- a/packages/core/tests/convention-index.test.ts +++ b/packages/core/tests/convention-index.test.ts @@ -335,6 +335,50 @@ test("Session threads cfg.conventions into the pull_conventions tool (real dispa } }); +test("Session advertises pull_conventions with the provider's topics as the enum (offer-time threading)", async () => { + // Observes the SCHEMA the Session actually advertises to the model: the tools handed to + // provider.complete must contain a pull_conventions tool whose enum is the injected provider's + // topics(). This is the end-to-end guard for session.ts threading cfg.conventions.topics() + // through toolsFor — swapping that for [] would drop the enum and fail this deep-equal. + const dir = await mkdtemp(join(tmpdir(), "tsforge-conv-schema-")); + const captured: { tools: readonly unknown[] | null } = { tools: null }; + + const provider: IProvider = { + async complete(_messages: IChatMessage[], opts) { + captured.tools = opts?.tools ?? []; + + return { content: "done", toolCalls: [] }; + }, + }; + + const topics = ["ADVERTISED_TOPIC_A", "ADVERTISED_TOPIC_B"]; + const fakeConv: IConventionProvider = { + buildGuides: () => "", + unseenForErrors: () => [], + guide: () => null, + topics: () => topics, + }; + + try { + const s = await Session.create({ + provider, + cwd: dir, + files: ["**/*"], + executionMode: "drive-to-green", + pullConventions: true, + conventions: fakeConv, + }); + + await s.send("go"); + + // The advertised set carries EXACTLY the tool built from the provider's topics — not the + // empty-provider free-form fallback, proving the topics were threaded (not dropped to []). + expect(captured.tools).toContainEqual(buildPullConventionsTool(topics)); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test("a hallucinated pull_conventions call with pullConventions OFF gets no provider (no withheld-capability leak)", async () => { // The provider is threaded into the tool context ONLY when pullConventions is on (the flag that // offers the tool). With the flag off, a hallucinated pull_conventions call executes (read-only, From 600da07edbea7fcb06dc3fb1217bec8f2800e16a Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 00:19:34 +0200 Subject: [PATCH 18/53] refactor(core): offer pull_conventions only with a provider + relocate conventions into the adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two WS1-finish steps: 1. Panel r3 residual (major/other, session.ts): gate the pull_conventions OFFER on provider presence, not just the pullConventions flag. Advertising a knowledge tool with no knowledge base is incoherent — dispatch would always return "no convention library" and the schema would falsely promise a topic listing. Now the tool is offered iff a provider is injected; the front-load guides + reactive push keep their separate pullConventions gate (a provider-present-but-flag-off session still must not front-load). New test: pullConventions on + no provider ⇒ pull_conventions is NOT advertised. 2. Relocate loop/conventions.ts → loop/boringstack/conventions.ts. The BoringStack convention CONTENT now lives entirely under the adapter dir; the only core-side surface is the generic IConventionProvider interface (loop/conventions-provider.ts, unchanged). Sole src importer was the adapter's build-config.ts; imports repointed. validate green (3242 pass, 0 fail). --- .../core/src/loop/boringstack/build-config.ts | 2 +- .../src/loop/{ => boringstack}/conventions.ts | 2 +- packages/core/src/loop/session.ts | 18 +++---- .../tests/boringstack-conventions.test.ts | 2 +- packages/core/tests/convention-index.test.ts | 47 ++++++++++++++++++- .../core/tests/conventions-provider.test.ts | 2 +- .../tests/drive-to-green-check-prompt.test.ts | 2 +- packages/core/tests/pull-conventions.test.ts | 2 +- 8 files changed, 62 insertions(+), 15 deletions(-) rename packages/core/src/loop/{ => boringstack}/conventions.ts (99%) diff --git a/packages/core/src/loop/boringstack/build-config.ts b/packages/core/src/loop/boringstack/build-config.ts index 3dc74a58..380956df 100644 --- a/packages/core/src/loop/boringstack/build-config.ts +++ b/packages/core/src/loop/boringstack/build-config.ts @@ -1,7 +1,7 @@ import type { ExecutionMode } from "../prompt"; import type { IPolicyRules } from "../../policy"; import type { IConventionProvider } from "../conventions-provider"; -import { boringstackConventionProvider } from "../conventions"; +import { boringstackConventionProvider } from "./conventions"; /** * Commands the model must NEVER run during a BoringStack build. The browser end-to-end diff --git a/packages/core/src/loop/conventions.ts b/packages/core/src/loop/boringstack/conventions.ts similarity index 99% rename from packages/core/src/loop/conventions.ts rename to packages/core/src/loop/boringstack/conventions.ts index 06c63135..91239fc9 100644 --- a/packages/core/src/loop/conventions.ts +++ b/packages/core/src/loop/boringstack/conventions.ts @@ -11,7 +11,7 @@ * wall. Each guide maps 1:1 to the rules that reject its violation. */ -import type { IConventionProvider } from "./conventions-provider"; +import type { IConventionProvider } from "../conventions-provider"; /** The convention topics the model can be handed or pull. Single source of truth: * the const tuple drives both the type and the runtime list (no `as` cast). */ diff --git a/packages/core/src/loop/session.ts b/packages/core/src/loop/session.ts index 88d4a39d..763e9470 100644 --- a/packages/core/src/loop/session.ts +++ b/packages/core/src/loop/session.ts @@ -800,19 +800,21 @@ export class Session { const offerCheck = cfg.offerCheck === true && cfg.executionMode === "drive-to-green"; - // The pull_conventions topic enum is built from the injected provider's real - // topics at offer time (stack-agnostic — core carries no topic literal). Gated - // on pullConventions to match the capability-bypass guard below: no capability, - // no topics. + // pull_conventions is offered only when the capability is on AND a provider is + // actually injected — advertising a knowledge tool with no knowledge base would + // promise a topic listing the dispatch can't deliver ("no convention library"), + // and its free-form schema falsely implies unknown topics return valid ones. The + // topic enum is then built from the injected provider's real topics() at offer + // time (stack-agnostic — core carries no topic literal). + const conventionProvider = + cfg.pullConventions === true ? cfg.conventions : undefined; const conventionTopics = - cfg.pullConventions === true && cfg.conventions !== undefined - ? cfg.conventions.topics() - : []; + conventionProvider !== undefined ? conventionProvider.topics() : []; this.tools = toolsFor( false, {}, - cfg.pullConventions === true, + conventionProvider !== undefined, offerCheck, cfg.interactive === true, conventionTopics diff --git a/packages/core/tests/boringstack-conventions.test.ts b/packages/core/tests/boringstack-conventions.test.ts index 114267b7..bea0b717 100644 --- a/packages/core/tests/boringstack-conventions.test.ts +++ b/packages/core/tests/boringstack-conventions.test.ts @@ -7,7 +7,7 @@ import { topicForRule, unseenGuidesForErrors, boringstackConventionProvider, -} from "../src/loop/conventions"; +} from "../src/loop/boringstack/conventions"; import { injectFeedback, type ILoopCtx } from "../src/loop/turn"; import type { ILoopState, ILoopEvent } from "../src/loop"; import type { IErrorItem } from "../src/validate"; diff --git a/packages/core/tests/convention-index.test.ts b/packages/core/tests/convention-index.test.ts index 70b53d2a..57867e87 100644 --- a/packages/core/tests/convention-index.test.ts +++ b/packages/core/tests/convention-index.test.ts @@ -8,7 +8,7 @@ import { conventionTopics, topicForRule, boringstackConventionProvider, -} from "../src/loop/conventions"; +} from "../src/loop/boringstack/conventions"; import type { IProvider, IChatMessage } from "../src/inference"; import type { IConventionProvider } from "../src/loop/conventions-provider"; import type { IGate } from "../src/gate/gate-runner"; @@ -379,6 +379,51 @@ test("Session advertises pull_conventions with the provider's topics as the enum } }); +test("Session does NOT advertise pull_conventions when the capability is on but no provider is injected", async () => { + // A knowledge tool with no knowledge base is incoherent: dispatch would always return "no + // convention library" and the schema would falsely promise a topic listing. So the tool is + // offered only when a provider is actually present — pullConventions alone is not enough. + const dir = await mkdtemp(join(tmpdir(), "tsforge-conv-noprov-")); + const captured: { names: readonly string[] } = { names: [] }; + + const provider: IProvider = { + async complete(_messages: IChatMessage[], opts) { + const tools = opts?.tools ?? []; + + captured.names = tools.map((t) => + t !== null && + typeof t === "object" && + "function" in t && + t.function !== null && + typeof t.function === "object" && + "name" in t.function && + typeof t.function.name === "string" + ? t.function.name + : "" + ); + + return { content: "done", toolCalls: [] }; + }, + }; + + try { + const s = await Session.create({ + provider, + cwd: dir, + files: ["**/*"], + executionMode: "drive-to-green", + pullConventions: true, + // no `conventions` provider injected + }); + + await s.send("go"); + + expect(captured.names).not.toContain("pull_conventions"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test("a hallucinated pull_conventions call with pullConventions OFF gets no provider (no withheld-capability leak)", async () => { // The provider is threaded into the tool context ONLY when pullConventions is on (the flag that // offers the tool). With the flag off, a hallucinated pull_conventions call executes (read-only, diff --git a/packages/core/tests/conventions-provider.test.ts b/packages/core/tests/conventions-provider.test.ts index 9d9ecdb8..96ac2352 100644 --- a/packages/core/tests/conventions-provider.test.ts +++ b/packages/core/tests/conventions-provider.test.ts @@ -1,6 +1,6 @@ import { test, expect } from "bun:test"; import type { IConventionProvider } from "../src/loop/conventions-provider"; -import { boringstackConventionProvider } from "../src/loop/conventions"; +import { boringstackConventionProvider } from "../src/loop/boringstack/conventions"; // Mirrored test for the IConventionProvider seam (loop/conventions-provider.ts). The interface is // type-only, so this pins its CONTRACT via the concrete BoringStack implementation: assignability diff --git a/packages/core/tests/drive-to-green-check-prompt.test.ts b/packages/core/tests/drive-to-green-check-prompt.test.ts index e0a84442..e2c64365 100644 --- a/packages/core/tests/drive-to-green-check-prompt.test.ts +++ b/packages/core/tests/drive-to-green-check-prompt.test.ts @@ -6,7 +6,7 @@ import { buildDriveToGreenSystem } from "../src/loop/prompt/prompt"; import { DEFAULT_CONVENTIONS } from "../src/infer-rules/conventions"; import type { IProvider, IChatMessage } from "../src/inference"; import { Session } from "../src/loop"; -import { boringstackConventionProvider } from "../src/loop/conventions"; +import { boringstackConventionProvider } from "../src/loop/boringstack/conventions"; // WS-A4: the drive-to-green system prompt must not CONTRADICT the check tool. When // check is offered (the boringstack build), the execution guidance promotes it; when diff --git a/packages/core/tests/pull-conventions.test.ts b/packages/core/tests/pull-conventions.test.ts index baa5e07f..964ce484 100644 --- a/packages/core/tests/pull-conventions.test.ts +++ b/packages/core/tests/pull-conventions.test.ts @@ -1,6 +1,6 @@ import { test, expect, describe } from "bun:test"; import { doPullConventions } from "../src/loop/tools/pull-conventions"; -import { boringstackConventionProvider } from "../src/loop/conventions"; +import { boringstackConventionProvider } from "../src/loop/boringstack/conventions"; // The tool reads the convention library from the injected provider (ctx.conventions), so a real // call supplies the BoringStack provider — the same one the adapter injects at runtime. From 0442e278894d583f7ad52c3af27895dfe1736bc0 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 00:29:47 +0200 Subject: [PATCH 19/53] fix(core): keep pull_conventions prompt inventory in lockstep with the tools list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS1-finish panel residuals (all three): - major/other (session.ts): the system-prompt tool inventory advertised pull_conventions off raw cfg.pullConventions while toolsFor gated it on provider presence — so pullConventions:true + no provider told the model a tool the tools list omitted (broke the flag↔prompt invariant). Add a single conventionsOffered(cfg) predicate (flag AND provider) and use it for BOTH the prompt (tool mention + guide front-load) and toolsFor. Test now also asserts the system prompt omits pull_conventions in the no-provider case. - minor/missing-test: the no-provider offer test could pass vacuously if complete never ran; assert the base tools are present (provider WAS called). - minor/dead-code: drop the stale 'still lives under loop/' relocation comment in the now-relocated conventions.ts. validate green (3242 pass, 0 fail). --- .../core/src/loop/boringstack/conventions.ts | 5 ++-- packages/core/src/loop/session.ts | 27 ++++++++++++------- packages/core/tests/convention-index.test.ts | 20 +++++++++++--- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/core/src/loop/boringstack/conventions.ts b/packages/core/src/loop/boringstack/conventions.ts index 91239fc9..21b26e65 100644 --- a/packages/core/src/loop/boringstack/conventions.ts +++ b/packages/core/src/loop/boringstack/conventions.ts @@ -465,9 +465,8 @@ export function unseenGuidesForErrors( * seam. The core loop (system-prompt front-load, reactive push, `pull_conventions` * tool) depends only on the INTERFACE — injected via `ISessionConfig.conventions` / * `ILoopCtx.tool.conventions`; this concrete provider is the BoringStack CONTENT, - * supplied by the boringstack adapter (`build-config.ts`). No core file outside the - * adapter imports it. (Residual: this module still physically lives under `loop/`; - * relocating it into `loop/boringstack/` is the last step of WS1.) + * supplied by the boringstack adapter (`build-config.ts`). This module lives under + * `loop/boringstack/`, so no core file outside the adapter imports it. */ export const boringstackConventionProvider: IConventionProvider = { buildGuides: buildConventionGuides, diff --git a/packages/core/src/loop/session.ts b/packages/core/src/loop/session.ts index 763e9470..b46805b3 100644 --- a/packages/core/src/loop/session.ts +++ b/packages/core/src/loop/session.ts @@ -596,6 +596,15 @@ function taskContract(files: string[], accept: string | undefined): string { .join("\n"); } +/** Whether `pull_conventions` is OFFERED — advertised in the system prompt AND exposed by + * `toolsFor`. Both must read this ONE predicate or they drift: the tool is offered only when + * the capability flag is on AND a provider is actually injected (a knowledge tool with no + * provider always returns "no convention library"). Keeping the prompt inventory and the + * advertised tool list in lockstep is the flag↔prompt invariant. */ +function conventionsOffered(cfg: ISessionConfig): boolean { + return cfg.pullConventions === true && cfg.conventions !== undefined; +} + /** The STATIC system policy (identity, tools, conventions, workspace map, guidance) + * the initial dynamic task contract. Base framing is mode-driven: `drive-to-green` * (autonomous builds) gets the strict expert-TS implement contract; `chat` (default) @@ -611,7 +620,7 @@ function systemPrompt( ? buildDriveToGreenSystem( conventions, cfg.offerCheck === true, - cfg.pullConventions === true + conventionsOffered(cfg) ) : buildChatSystem(conventions); @@ -634,10 +643,9 @@ function systemPrompt( // topic is in the prompt UP FRONT, so the model writes it right the FIRST time — the Bucket-1 // fix. The reactive PUSH (unseenGuidesForErrors) and `pull_conventions` remain as fallbacks // for reinforcement and the long tail, not the primary teaching. - const conv = - cfg.pullConventions === true - ? `${cfg.conventions?.buildGuides() ?? ""}\n\n` - : ""; + const conv = conventionsOffered(cfg) + ? `${cfg.conventions?.buildGuides() ?? ""}\n\n` + : ""; const contract = taskContract(cfg.files ?? [], cfg.accept); @@ -806,15 +814,16 @@ export class Session { // and its free-form schema falsely implies unknown topics return valid ones. The // topic enum is then built from the injected provider's real topics() at offer // time (stack-agnostic — core carries no topic literal). - const conventionProvider = - cfg.pullConventions === true ? cfg.conventions : undefined; + const offerConventions = conventionsOffered(cfg); const conventionTopics = - conventionProvider !== undefined ? conventionProvider.topics() : []; + offerConventions && cfg.conventions !== undefined + ? cfg.conventions.topics() + : []; this.tools = toolsFor( false, {}, - conventionProvider !== undefined, + offerConventions, offerCheck, cfg.interactive === true, conventionTopics diff --git a/packages/core/tests/convention-index.test.ts b/packages/core/tests/convention-index.test.ts index 57867e87..9352114e 100644 --- a/packages/core/tests/convention-index.test.ts +++ b/packages/core/tests/convention-index.test.ts @@ -384,10 +384,17 @@ test("Session does NOT advertise pull_conventions when the capability is on but // convention library" and the schema would falsely promise a topic listing. So the tool is // offered only when a provider is actually present — pullConventions alone is not enough. const dir = await mkdtemp(join(tmpdir(), "tsforge-conv-noprov-")); - const captured: { names: readonly string[] } = { names: [] }; + const captured: { names: readonly string[]; system: string } = { + names: [], + system: "", + }; const provider: IProvider = { - async complete(_messages: IChatMessage[], opts) { + async complete(messages: IChatMessage[], opts) { + const sys = messages.find((m) => m.role === "system"); + + captured.system = typeof sys?.content === "string" ? sys.content : ""; + const tools = opts?.tools ?? []; captured.names = tools.map((t) => @@ -418,7 +425,14 @@ test("Session does NOT advertise pull_conventions when the capability is on but await s.send("go"); - expect(captured.names).not.toContain("pull_conventions"); + // Non-vacuous: the provider WAS called with a real tool list (base tools present), so the + // absence of pull_conventions is a genuine observation, not an empty captured.names. + expect(captured.names).toContain(TOOL_NAME.read); + expect(captured.names).not.toContain(TOOL_NAME.pullConventions); + + // The flag↔prompt invariant: the system prompt's tool inventory must NOT advertise + // pull_conventions either — otherwise the model is told about a tool the tools list omits. + expect(captured.system).not.toContain("pull_conventions"); } finally { await rm(dir, { recursive: true, force: true }); } From 5a6b521f73be362c66f293ee248438f9042b4460 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 00:42:59 +0200 Subject: [PATCH 20/53] refactor(core): resolve the stack via a generic IStackAdapter seam (WS2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI (composition root) no longer imports BoringStack planning specifics directly. New core seam loop/planning/stack-adapter.ts: IStackAdapter ({id, detect(dir), planConstraints(onStripped)}) + resolveStackAdapter(dir, adapters) — the generic greenfield flow knows only this interface, never a concrete stack. Moved loop/planning/boringstack-planning.ts → loop/boringstack/planning.ts and added boringstackStackAdapter there. cli/repl.ts holds a one-line adapter registry and drives a stack-agnostic greenfield-planning flow (detection AND constraints from the SAME resolved adapter — no gap). Adding a stack (Phaser next) is a registry line, not a change to planning logic. Tests: stack-adapter.test.ts (resolve first-match / none→null / empty; the boringstack adapter is well-formed + carries the reserved-entity fail-closed rule + detect false without a receipt); planning test imports repointed. validate green (3248 pass, 0 fail). --- packages/core/src/cli/repl.ts | 53 ++++++++----- .../planning.ts} | 15 +++- .../core/src/loop/planning/stack-adapter.ts | 42 +++++++++++ .../core/tests/boringstack-planning.test.ts | 2 +- packages/core/tests/propose-plan.test.ts | 2 +- packages/core/tests/stack-adapter.test.ts | 75 +++++++++++++++++++ 6 files changed, 166 insertions(+), 23 deletions(-) rename packages/core/src/loop/{planning/boringstack-planning.ts => boringstack/planning.ts} (85%) create mode 100644 packages/core/src/loop/planning/stack-adapter.ts create mode 100644 packages/core/tests/stack-adapter.test.ts diff --git a/packages/core/src/cli/repl.ts b/packages/core/src/cli/repl.ts index 79b632bc..7afa2953 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -44,9 +44,10 @@ import { } from "../loop"; import { runPlanning } from "../loop/planning/run-planning"; import { - isBoringstackProject, - boringstackPlanConstraints, -} from "../loop/planning/boringstack-planning"; + resolveStackAdapter, + type IStackAdapter, +} from "../loop/planning/stack-adapter"; +import { boringstackStackAdapter } from "../loop/boringstack/planning"; import { loadApprovedPlan } from "../loop/planning/plan-store"; import { loadRecipes } from "../config/recipes"; import { loadAgentSpecs } from "../config/agent-specs"; @@ -158,17 +159,23 @@ export function parseReviewResponse( return { action: "revise", note: response }; } -/** The greenfield BoringStack planning flow (description → proposed plan → human +/** The stack adapters the CLI (composition root) registers. The generic planner/CLI + * flow resolves the matching one per project; adding a stack (Phaser next) is a one-line + * registration here, not a change to the planning logic. */ +const STACK_ADAPTERS: readonly IStackAdapter[] = [boringstackStackAdapter]; + +/** The greenfield planning flow (description → proposed plan → human * approve/revise/cancel → approved plan on disk). Extracted from the line handler - * to keep its cognitive complexity down; the stack detection + planner constraints - * live in the tested boringstack-planning module, and this only glues them to the - * interactive prompt. */ + * to keep its cognitive complexity down; the resolved stack adapter supplies the + * planner constraints (guidance + reserved-entity stripping) and this only glues them + * to the interactive prompt — it names no concrete stack. */ async function runGreenfieldPlanning( dir: string, description: string, echo: (s: string) => void, rl: ReturnType | null, - activeModelEntry: IModelEntry + activeModelEntry: IModelEntry, + stack: IStackAdapter ): Promise { echo("▸ planning your product first...\n"); @@ -179,11 +186,11 @@ async function runGreenfieldPlanning( const result = await runPlanning(dir, { planner: plannerProvider, - // We only reach here when looksLikeBoringstack is true, so the BoringStack + // We only reach here when this stack adapter detected the project, so its // reserved-slice rule always applies (no gap) and every drop is surfaced. - constraints: boringstackPlanConstraints((dropped) => { + constraints: stack.planConstraints((dropped) => { echo( - `▸ dropped auth slice(s) BoringStack already provides: ${dropped.join(", ")}\n` + `▸ dropped slice(s) the ${stack.id} starter already provides: ${dropped.join(", ")}\n` ); }), describe: async () => { @@ -875,15 +882,21 @@ export async function repl(args: ICliArgs): Promise { return; } - // GREENFIELD BORINGSTACK INTERCEPTION: a fresh boringstack project with no - // approved plan routes into planning first. Detection + planner constraints - // share ONE structural signal (looksLikeBoringstack) so there is no gap where a - // project is planned as boringstack but not given the reserved-slice rule. - if ( - (await isBoringstackProject(args.dir)) && - (await loadApprovedPlan(args.dir)) === null - ) { - await runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry); + // GREENFIELD INTERCEPTION: a fresh project a stack adapter claims, with no approved + // plan, routes into planning first. Detection AND the planner constraints come from + // the SAME resolved adapter, so there is no gap where a project is planned by a stack + // but not given its reserved-slice rule. + const stack = await resolveStackAdapter(args.dir, STACK_ADAPTERS); + + if (stack !== null && (await loadApprovedPlan(args.dir)) === null) { + await runGreenfieldPlanning( + args.dir, + line, + echo, + rl, + activeModelEntry, + stack + ); return; } diff --git a/packages/core/src/loop/planning/boringstack-planning.ts b/packages/core/src/loop/boringstack/planning.ts similarity index 85% rename from packages/core/src/loop/planning/boringstack-planning.ts rename to packages/core/src/loop/boringstack/planning.ts index 64dff8d1..fe766cae 100644 --- a/packages/core/src/loop/planning/boringstack-planning.ts +++ b/packages/core/src/loop/boringstack/planning.ts @@ -1,7 +1,8 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { isRecord } from "../../lib/guards"; -import type { IPlanConstraints } from "./plan-types"; +import type { IPlanConstraints } from "../planning/plan-types"; +import type { IStackAdapter } from "../planning/stack-adapter"; /** STACK-SPECIFIC planner guidance for BoringStack (kept OUT of the generic * planner). Appended to the system prompt only for a BoringStack project. The @@ -80,3 +81,15 @@ export function boringstackPlanConstraints( onStripped, }; } + +/** + * The BoringStack stack adapter as the generic greenfield flow sees it (`IStackAdapter`). + * This is the single registration point the composition root (the CLI) imports; the core + * planning logic depends only on the interface, never on `isBoringstackProject` / + * `boringstackPlanConstraints` directly. + */ +export const boringstackStackAdapter: IStackAdapter = { + id: "boringstack", + detect: (dir) => isBoringstackProject(dir), + planConstraints: boringstackPlanConstraints, +}; diff --git a/packages/core/src/loop/planning/stack-adapter.ts b/packages/core/src/loop/planning/stack-adapter.ts new file mode 100644 index 00000000..9c7889c3 --- /dev/null +++ b/packages/core/src/loop/planning/stack-adapter.ts @@ -0,0 +1,42 @@ +import type { IPlanConstraints } from "./plan-types"; + +/** + * A STACK adapter as the generic planner/CLI sees it. The core greenfield flow knows + * only this interface — it never names a concrete stack. An adapter answers two + * questions for a project directory: + * - `detect(dir)`: is this project mine? (e.g. a scaffold receipt) + * - `planConstraints(onStripped)`: the stack-specific planner constraints (guidance + + * reserved-entity stripping), fail-closed via `IPlanConstraints`. + * Concrete adapters (BoringStack today, Phaser next) live under their own adapter + * directory and are registered by the composition root (the CLI), never imported by + * core planning logic. + */ +export interface IStackAdapter { + /** Stable id, e.g. "boringstack" — used in surfaced messages, not for control flow. */ + readonly id: string; + /** Whether this adapter owns the project at `dir` (authoritative, no false positives). */ + detect(dir: string): Promise; + /** The stack's planner constraints; `onStripped` surfaces every dropped entity id. */ + planConstraints( + onStripped: (droppedEntityIds: readonly string[]) => void + ): IPlanConstraints; +} + +/** + * Resolve the FIRST registered adapter that claims the project at `dir`, or null if none + * do (a plain/unknown project — no stack-specific planning). Adapters are tried in order, + * so the registry order is the precedence; detection is expected to be mutually exclusive + * (each adapter keys on its own authoritative signal), so order is not load-bearing today. + */ +export async function resolveStackAdapter( + dir: string, + adapters: readonly IStackAdapter[] +): Promise { + for (const adapter of adapters) { + if (await adapter.detect(dir)) { + return adapter; + } + } + + return null; +} diff --git a/packages/core/tests/boringstack-planning.test.ts b/packages/core/tests/boringstack-planning.test.ts index c1e9ef05..a6395967 100644 --- a/packages/core/tests/boringstack-planning.test.ts +++ b/packages/core/tests/boringstack-planning.test.ts @@ -4,7 +4,7 @@ import { boringstackPlanConstraints, BORINGSTACK_PLANNER_GUIDANCE, BORINGSTACK_RESERVED_ENTITY_IDS, -} from "../src/loop/planning/boringstack-planning"; +} from "../src/loop/boringstack/planning"; describe("isBoringstackProject (authoritative scaffold-receipt detection)", () => { test("true only when the receipt records archetype boringstack", async () => { diff --git a/packages/core/tests/propose-plan.test.ts b/packages/core/tests/propose-plan.test.ts index 02759edf..9d5e0656 100644 --- a/packages/core/tests/propose-plan.test.ts +++ b/packages/core/tests/propose-plan.test.ts @@ -9,7 +9,7 @@ import { import { BORINGSTACK_PLANNER_GUIDANCE, BORINGSTACK_RESERVED_ENTITY_IDS, -} from "../src/loop/planning/boringstack-planning"; +} from "../src/loop/boringstack/planning"; import type { IProductPlan, ISlice } from "../src/loop/planning/plan-types"; import { isProductPlan } from "../src/loop/planning/plan-store"; import type { IProvider } from "../src/inference"; diff --git a/packages/core/tests/stack-adapter.test.ts b/packages/core/tests/stack-adapter.test.ts new file mode 100644 index 00000000..188ffe44 --- /dev/null +++ b/packages/core/tests/stack-adapter.test.ts @@ -0,0 +1,75 @@ +import { test, expect, describe } from "bun:test"; +import { + resolveStackAdapter, + type IStackAdapter, +} from "../src/loop/planning/stack-adapter"; +import { boringstackStackAdapter } from "../src/loop/boringstack/planning"; + +/** A fake adapter whose detection and id are fixed, so resolution order/precedence is + * observable without touching the filesystem. */ +const fake = (id: string, matches: boolean): IStackAdapter => ({ + id, + detect: async () => { + await Promise.resolve(); + + return matches; + }, + // guidance-only constraint (no reserved entities) — these fakes exist for resolution + // order, so planConstraints is never invoked; a valid minimal value keeps types honest. + planConstraints: () => ({ guidance: id }), +}); + +describe("resolveStackAdapter", () => { + test("returns the FIRST adapter that claims the project", async () => { + const a = fake("a", false); + const b = fake("b", true); + const c = fake("c", true); + + const resolved = await resolveStackAdapter("/some/dir", [a, b, c]); + + expect(resolved?.id).toBe("b"); + }); + + test("returns null when no adapter claims the project", async () => { + const resolved = await resolveStackAdapter("/some/dir", [ + fake("a", false), + fake("b", false), + ]); + + expect(resolved).toBeNull(); + }); + + test("returns null for an empty registry", async () => { + expect(await resolveStackAdapter("/some/dir", [])).toBeNull(); + }); +}); + +describe("boringstackStackAdapter", () => { + test("is a well-formed IStackAdapter with the boringstack id", () => { + const adapter: IStackAdapter = boringstackStackAdapter; + + expect(adapter.id).toBe("boringstack"); + expect(typeof adapter.detect).toBe("function"); + expect(typeof adapter.planConstraints).toBe("function"); + }); + + test("planConstraints carries the reserved-entity rule and surfaces drops (fail-closed)", () => { + const dropped: string[][] = []; + const constraints = boringstackStackAdapter.planConstraints((ids) => + dropped.push([...ids]) + ); + + // The boringstack constraint strips the reserved auth entities and REQUIRES a reporter. + expect(constraints.reservedEntities?.has("user")).toBe(true); + expect(constraints.guidance).toContain("BoringStack"); + constraints.onStripped?.(["user"]); + expect(dropped).toEqual([["user"]]); + }); + + test("detect is false for a directory with no boringstack scaffold receipt", async () => { + // No .tsforge/scaffold.json under a temp-ish path ⇒ not a boringstack project. + expect( + await boringstackStackAdapter.detect("/definitely/not/a/project") + ).toBe(false); + }); +}); From c23a4f0a0af610db3e8ec289d6914f4214d999a3 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 00:58:57 +0200 Subject: [PATCH 21/53] test(core): cover the REPL stack-adapter dispatch decision + wiring (WS2 r1 fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel r1 BLOCK (3 findings): - major/missing-test: no REPL-level test proved a detected adapter triggers planning with the EXACT resolved adapter's constraints, or that unmatched / already-planned projects bypass. Extract two pure, exported seams from the line handler — resolveGreenfieldStack(dir, adapters, hasApprovedPlan) (the interception decision) and greenfieldConstraints(stack, echo) (resolved adapter → planner constraints, drops routed to echo) — and test both: detected+no-plan→exact adapter, detected+planned→null, none→null, short-circuit; constraints carry the adapter's rule + surface drops via echo. - minor/missing-test: assert boringstackStackAdapter.detect TRUE through the adapter method for a real .tsforge/scaffold.json receipt (a false-returning detect stub would now fail). - minor/other: correct the IStackAdapter.planConstraints doc — the type is fail-closed (a reporter is always required) but cannot force forwarding the CALLER's reporter; that is the adapter contract, verified per-adapter by test. validate green (3254 pass, 0 fail). --- packages/core/src/cli/repl.ts | 54 +++++++++-- .../core/src/loop/planning/stack-adapter.ts | 8 +- .../core/tests/repl-greenfield-stack.test.ts | 91 +++++++++++++++++++ packages/core/tests/stack-adapter.test.ts | 22 +++++ 4 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 packages/core/tests/repl-greenfield-stack.test.ts diff --git a/packages/core/src/cli/repl.ts b/packages/core/src/cli/repl.ts index 7afa2953..2c995473 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -43,6 +43,7 @@ import { type ILoopEvent, } from "../loop"; import { runPlanning } from "../loop/planning/run-planning"; +import type { IPlanConstraints } from "../loop/planning/plan-types"; import { resolveStackAdapter, type IStackAdapter, @@ -164,6 +165,45 @@ export function parseReviewResponse( * registration here, not a change to the planning logic. */ const STACK_ADAPTERS: readonly IStackAdapter[] = [boringstackStackAdapter]; +/** + * The greenfield-planning DECISION: which registered stack adapter (if any) should + * intercept `dir` for planning. Returns the adapter to plan with — a stack detected the + * project AND it has no approved plan yet — or null to proceed normally (no stack detected, + * or already planned). Detection and the planner constraints then come from this SAME + * returned adapter, so there is no gap. Pure + injectable (`hasApprovedPlan`) so the + * interception rule is unit-testable without driving the REPL. + */ +export async function resolveGreenfieldStack( + dir: string, + adapters: readonly IStackAdapter[], + hasApprovedPlan: (dir: string) => Promise +): Promise { + const stack = await resolveStackAdapter(dir, adapters); + + if (stack === null) { + return null; + } + + return (await hasApprovedPlan(dir)) ? null : stack; +} + +/** + * Build the planner constraints for the greenfield flow from the RESOLVED stack adapter, + * wiring its drop reporter to `echo` so every stripped slice is surfaced to the user. Kept + * separate + exported so the "resolved adapter supplies the constraints, and drops reach + * the echo sink" wiring is testable without running the whole planner. + */ +export function greenfieldConstraints( + stack: IStackAdapter, + echo: (s: string) => void +): IPlanConstraints { + return stack.planConstraints((dropped) => { + echo( + `▸ dropped slice(s) the ${stack.id} starter already provides: ${dropped.join(", ")}\n` + ); + }); +} + /** The greenfield planning flow (description → proposed plan → human * approve/revise/cancel → approved plan on disk). Extracted from the line handler * to keep its cognitive complexity down; the resolved stack adapter supplies the @@ -188,11 +228,7 @@ async function runGreenfieldPlanning( planner: plannerProvider, // We only reach here when this stack adapter detected the project, so its // reserved-slice rule always applies (no gap) and every drop is surfaced. - constraints: stack.planConstraints((dropped) => { - echo( - `▸ dropped slice(s) the ${stack.id} starter already provides: ${dropped.join(", ")}\n` - ); - }), + constraints: greenfieldConstraints(stack, echo), describe: async () => { await Promise.resolve(); @@ -886,9 +922,13 @@ export async function repl(args: ICliArgs): Promise { // plan, routes into planning first. Detection AND the planner constraints come from // the SAME resolved adapter, so there is no gap where a project is planned by a stack // but not given its reserved-slice rule. - const stack = await resolveStackAdapter(args.dir, STACK_ADAPTERS); + const stack = await resolveGreenfieldStack( + args.dir, + STACK_ADAPTERS, + async (d) => (await loadApprovedPlan(d)) !== null + ); - if (stack !== null && (await loadApprovedPlan(args.dir)) === null) { + if (stack !== null) { await runGreenfieldPlanning( args.dir, line, diff --git a/packages/core/src/loop/planning/stack-adapter.ts b/packages/core/src/loop/planning/stack-adapter.ts index 9c7889c3..68341ef8 100644 --- a/packages/core/src/loop/planning/stack-adapter.ts +++ b/packages/core/src/loop/planning/stack-adapter.ts @@ -16,7 +16,13 @@ export interface IStackAdapter { readonly id: string; /** Whether this adapter owns the project at `dir` (authoritative, no false positives). */ detect(dir: string): Promise; - /** The stack's planner constraints; `onStripped` surfaces every dropped entity id. */ + /** + * The stack's planner constraints. `IPlanConstraints` is fail-closed at the TYPE level: + * `reservedEntities` can only be set together with SOME `onStripped`, so a strip is never + * silently dropped. The type cannot force an adapter to forward the CALLER's `onStripped` + * (an adapter could attach its own) — that it forwards the passed reporter to the caller's + * sink is the adapter's contract, verified per-adapter by test (see stack-adapter.test.ts). + */ planConstraints( onStripped: (droppedEntityIds: readonly string[]) => void ): IPlanConstraints; diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts new file mode 100644 index 00000000..1a173cc3 --- /dev/null +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -0,0 +1,91 @@ +import { test, expect, describe } from "bun:test"; +import { resolveGreenfieldStack, greenfieldConstraints } from "../src/cli/repl"; +import type { IStackAdapter } from "../src/loop/planning/stack-adapter"; + +/** A stub adapter with fixed id + detection. `planConstraints` FORWARDS the caller's + * reporter into the returned constraint, so greenfieldConstraints' echo wiring is + * observable (the adapter contract the real boringstack adapter also honors). */ +const stub = (id: string, matches: boolean): IStackAdapter => ({ + id, + detect: () => Promise.resolve(matches), + planConstraints: (onStripped) => ({ + reservedEntities: new Set([`${id}-reserved`]), + onStripped, + }), +}); + +const noApprovedPlan = (): Promise => Promise.resolve(false); +const hasApprovedPlan = (): Promise => Promise.resolve(true); + +describe("resolveGreenfieldStack (the REPL interception decision)", () => { + test("a detected project with NO approved plan → returns the EXACT detected adapter", async () => { + const first = stub("other", false); + const detected = stub("boringstack", true); + + const resolved = await resolveGreenfieldStack( + "/dir", + [first, detected], + noApprovedPlan + ); + + // Not merely non-null — the SAME adapter object, so a wrong-adapter wiring would fail. + expect(resolved).toBe(detected); + }); + + test("a detected project that is ALREADY planned → null (bypass, no re-planning)", async () => { + const resolved = await resolveGreenfieldStack( + "/dir", + [stub("boringstack", true)], + hasApprovedPlan + ); + + expect(resolved).toBeNull(); + }); + + test("no adapter detects the project → null (proceed normally)", async () => { + const resolved = await resolveGreenfieldStack( + "/dir", + [stub("a", false), stub("b", false)], + noApprovedPlan + ); + + expect(resolved).toBeNull(); + }); + + test("hasApprovedPlan is only consulted for a detected project (short-circuit)", async () => { + let consulted = false; + const resolved = await resolveGreenfieldStack( + "/dir", + [stub("a", false)], + () => { + consulted = true; + + return Promise.resolve(false); + } + ); + + expect(resolved).toBeNull(); + expect(consulted).toBe(false); + }); +}); + +describe("greenfieldConstraints (resolved adapter supplies the constraints)", () => { + test("uses the RESOLVED adapter's planConstraints and routes drops to the echo sink", () => { + const echoed: string[] = []; + const constraints = greenfieldConstraints(stub("boringstack", true), (s) => + echoed.push(s) + ); + + // The adapter's own reserved-entity rule is carried through… + expect(constraints.reservedEntities?.has("boringstack-reserved")).toBe( + true + ); + + // …and a strip is surfaced to the user via echo, naming the resolved stack. + constraints.onStripped?.(["user", "auth"]); + const out = echoed.join(""); + + expect(out).toContain("boringstack"); + expect(out).toContain("user, auth"); + }); +}); diff --git a/packages/core/tests/stack-adapter.test.ts b/packages/core/tests/stack-adapter.test.ts index 188ffe44..0cef4a11 100644 --- a/packages/core/tests/stack-adapter.test.ts +++ b/packages/core/tests/stack-adapter.test.ts @@ -1,4 +1,7 @@ import { test, expect, describe } from "bun:test"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { resolveStackAdapter, type IStackAdapter, @@ -72,4 +75,23 @@ describe("boringstackStackAdapter", () => { await boringstackStackAdapter.detect("/definitely/not/a/project") ).toBe(false); }); + + test("detect is TRUE through the adapter method for a real boringstack scaffold receipt", async () => { + // The true path THROUGH boringstackStackAdapter.detect (not just isBoringstackProject) — + // so a detect stub that always returned false would fail here, catching a broken wiring + // that kills greenfield interception. + const dir = await mkdtemp(join(tmpdir(), "tsforge-detect-")); + + try { + await mkdir(join(dir, ".tsforge"), { recursive: true }); + await writeFile( + join(dir, ".tsforge", "scaffold.json"), + JSON.stringify({ archetype: "boringstack" }) + ); + + expect(await boringstackStackAdapter.detect(dir)).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); }); From 40e3257f35dd9fdc8dc8bd0116c5d8a3cd287405 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 01:06:48 +0200 Subject: [PATCH 22/53] test(core): source-guard that the REPL line handler is wired to the greenfield seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel r2 residual (agreement-1): the seam tests invoke resolveGreenfieldStack / greenfieldConstraints directly but never prove the REPL dispatch path calls them — removing the interception block or routing a greenfield line to runSend would leave them green. The interception lives inside repl()'s readline line handler (a closure), the same not-unit-reachable class the /clear + --continue wiring is already source-guarded for. Add a source guard asserting the handler calls resolveGreenfieldStack (definition + call site), routes to runGreenfieldPlanning, uses greenfieldConstraints(stack, echo), and resolves against STACK_ADAPTERS. validate green (3255 pass, 0 fail). --- .../core/tests/repl-greenfield-stack.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index 1a173cc3..423f877f 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -1,4 +1,5 @@ import { test, expect, describe } from "bun:test"; +import { join } from "node:path"; import { resolveGreenfieldStack, greenfieldConstraints } from "../src/cli/repl"; import type { IStackAdapter } from "../src/loop/planning/stack-adapter"; @@ -89,3 +90,28 @@ describe("greenfieldConstraints (resolved adapter supplies the constraints)", () expect(out).toContain("user, auth"); }); }); + +// The interception itself lives inside the REPL's readline line handler (a closure in +// `repl()`), which is not unit-reachable — the same class the /clear + --continue wiring is +// source-guarded for (see repl-ask-user-wiring.test.ts). Without this, removing the +// interception block or routing a greenfield line straight to runSend would leave every +// behavioral test above green. This locks that the dispatch is WIRED to the extracted seams. +describe("the REPL line handler is wired to the greenfield seams (source guard)", () => { + test("dispatch calls resolveGreenfieldStack, routes to planning, and uses greenfieldConstraints", async () => { + const src = await Bun.file( + join(import.meta.dir, "..", "src", "cli", "repl.ts") + ).text(); + + // resolveGreenfieldStack appears TWICE — its definition AND its call in the line handler + // (so a definition with no call would fail this). + expect( + (src.match(/resolveGreenfieldStack\(/g) ?? []).length + ).toBeGreaterThanOrEqual(2); + // A resolved stack routes into planning, not the normal send path. + expect(src).toContain("runGreenfieldPlanning("); + // …and the planner constraints come from the resolved adapter via greenfieldConstraints. + expect(src).toContain("greenfieldConstraints(stack, echo)"); + // The registry the dispatch resolves against is the composition-root adapter list. + expect(src).toContain("STACK_ADAPTERS"); + }); +}); From 359255332635c1c06699783c4a1feb716219c1cf Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 01:17:12 +0200 Subject: [PATCH 23/53] refactor(core): extract greenfieldOrSend so the interception branch is unit-tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel r3 BLOCK: the source-guard used independent whole-file string probes (comments/imports could satisfy them; nothing bound STACK_ADAPTERS into the resolve call or the resolved stack into the planning branch). Fix the DESIGN, not the probe: extract greenfieldOrSend(dir, adapters, hasApprovedPlan, onGreenfield, onSend) — the interception BRANCH itself (resolve → plan vs send, exactly one runs). The readline handler now just supplies the two continuations. Now the property the reviewers wanted is BEHAVIORAL unit test, not a probe: detected+unplanned → onGreenfield with the EXACT stack, never onSend; undetected → onSend never onGreenfield; detected+already-planned → onSend. The remaining thin glue (handler calls greenfieldOrSend with STACK_ADAPTERS + both real continuations) is guarded by a SINGLE bound regex over comment-stripped source — greenfieldOrSend(args.dir, STACK_ADAPTERS, … runGreenfieldPlanning … runSend(line)) — so a wrong registry, define-but-never-call, or swapped branch breaks the one match. validate green (3258 pass, 0 fail). --- packages/core/src/cli/repl.ts | 67 +++++---- .../core/tests/repl-greenfield-stack.test.ts | 127 +++++++++++++++--- 2 files changed, 150 insertions(+), 44 deletions(-) diff --git a/packages/core/src/cli/repl.ts b/packages/core/src/cli/repl.ts index 2c995473..8462d3ab 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -204,6 +204,31 @@ export function greenfieldConstraints( }); } +/** + * Route ONE input line: if a registered stack adapter claims the project (fresh + unplanned) + * hand the RESOLVED adapter to `onGreenfield`; otherwise fall through to `onSend` (the normal + * agent turn). EXACTLY ONE of the two runs. This encapsulates the interception BRANCH so its + * behavior — the resolved stack controls the planning path, and an unmatched/already-planned + * project falls through to the normal send — is unit-tested, not merely source-probed. + */ +export async function greenfieldOrSend( + dir: string, + adapters: readonly IStackAdapter[], + hasApprovedPlan: (dir: string) => Promise, + onGreenfield: (stack: IStackAdapter) => Promise, + onSend: () => Promise +): Promise { + const stack = await resolveGreenfieldStack(dir, adapters, hasApprovedPlan); + + if (stack !== null) { + await onGreenfield(stack); + + return; + } + + await onSend(); +} + /** The greenfield planning flow (description → proposed plan → human * approve/revise/cancel → approved plan on disk). Extracted from the line handler * to keep its cognitive complexity down; the resolved stack adapter supplies the @@ -918,33 +943,27 @@ export async function repl(args: ICliArgs): Promise { return; } - // GREENFIELD INTERCEPTION: a fresh project a stack adapter claims, with no approved - // plan, routes into planning first. Detection AND the planner constraints come from - // the SAME resolved adapter, so there is no gap where a project is planned by a stack - // but not given its reserved-slice rule. - const stack = await resolveGreenfieldStack( + // GREENFIELD INTERCEPTION vs NORMAL SEND. A fresh project a stack adapter claims, with + // no approved plan, routes into planning first (detection AND the planner constraints + // from the SAME resolved adapter — no gap). Otherwise the AGENT decides: it calls + // `scaffold_web` itself for a from-scratch web app, and just answers/edits otherwise (so + // "render a table in the CLI" is no longer mis-scaffolded as a Vite app). The branch + // itself is greenfieldOrSend (unit-tested); this only supplies the two continuations. + await greenfieldOrSend( args.dir, STACK_ADAPTERS, - async (d) => (await loadApprovedPlan(d)) !== null + async (d) => (await loadApprovedPlan(d)) !== null, + (stack) => + runGreenfieldPlanning( + args.dir, + line, + echo, + rl, + activeModelEntry, + stack + ), + () => runSend(line) ); - - if (stack !== null) { - await runGreenfieldPlanning( - args.dir, - line, - echo, - rl, - activeModelEntry, - stack - ); - - return; - } - - // No up-front classifier: the AGENT decides. It calls `scaffold_web` itself - // when the request is a from-scratch web app, and just answers/edits otherwise - // (so "render a table in the CLI" is no longer mis-scaffolded as a Vite app). - await runSend(line); }; // Placeholder declarations; defined after runLine / editorControl are available. diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index 423f877f..cc5fc255 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -1,6 +1,10 @@ import { test, expect, describe } from "bun:test"; import { join } from "node:path"; -import { resolveGreenfieldStack, greenfieldConstraints } from "../src/cli/repl"; +import { + resolveGreenfieldStack, + greenfieldConstraints, + greenfieldOrSend, +} from "../src/cli/repl"; import type { IStackAdapter } from "../src/loop/planning/stack-adapter"; /** A stub adapter with fixed id + detection. `planConstraints` FORWARDS the caller's @@ -91,27 +95,110 @@ describe("greenfieldConstraints (resolved adapter supplies the constraints)", () }); }); -// The interception itself lives inside the REPL's readline line handler (a closure in -// `repl()`), which is not unit-reachable — the same class the /clear + --continue wiring is -// source-guarded for (see repl-ask-user-wiring.test.ts). Without this, removing the -// interception block or routing a greenfield line straight to runSend would leave every -// behavioral test above green. This locks that the dispatch is WIRED to the extracted seams. -describe("the REPL line handler is wired to the greenfield seams (source guard)", () => { - test("dispatch calls resolveGreenfieldStack, routes to planning, and uses greenfieldConstraints", async () => { - const src = await Bun.file( +// greenfieldOrSend IS the interception branch. Testing it behaviorally proves the property +// the readline handler relies on — the resolved stack controls the planning path, and an +// unmatched/already-planned project falls through to the normal send — without driving the +// whole REPL. EXACTLY ONE continuation runs. +describe("greenfieldOrSend (the interception branch)", () => { + test("a detected+unplanned project runs onGreenfield with the EXACT stack, never onSend", async () => { + const detected = stub("boringstack", true); + const captured: { stack: IStackAdapter | null; sent: boolean } = { + stack: null, + sent: false, + }; + + await greenfieldOrSend( + "/dir", + [stub("other", false), detected], + noApprovedPlan, + (s) => { + captured.stack = s; + + return Promise.resolve(); + }, + () => { + captured.sent = true; + + return Promise.resolve(); + } + ); + + expect(captured.stack).toBe(detected); + expect(captured.sent).toBe(false); + }); + + test("an undetected project runs onSend, never onGreenfield", async () => { + let planned = false; + let sent = false; + + await greenfieldOrSend( + "/dir", + [stub("a", false)], + noApprovedPlan, + () => { + planned = true; + + return Promise.resolve(); + }, + () => { + sent = true; + + return Promise.resolve(); + } + ); + + expect(sent).toBe(true); + expect(planned).toBe(false); + }); + + test("a detected but ALREADY-planned project runs onSend, never onGreenfield", async () => { + let planned = false; + let sent = false; + + await greenfieldOrSend( + "/dir", + [stub("boringstack", true)], + hasApprovedPlan, + () => { + planned = true; + + return Promise.resolve(); + }, + () => { + sent = true; + + return Promise.resolve(); + } + ); + + expect(sent).toBe(true); + expect(planned).toBe(false); + }); +}); + +// The remaining glue — the readline line handler CALLING greenfieldOrSend with the +// composition-root registry and the two real continuations — lives inside repl()'s readline +// closure, which is not unit-reachable (the same class the /clear + --continue wiring is +// source-guarded for; see repl-ask-user-wiring.test.ts). A SINGLE bound regex over +// comment-stripped source proves the pieces are wired TOGETHER (not independent presence +// probes): greenfieldOrSend(args.dir, STACK_ADAPTERS, , , ). Removing the interception or swapping a +// callback breaks the single match. +describe("the REPL line handler wires greenfieldOrSend (source guard)", () => { + test("dispatch calls greenfieldOrSend with STACK_ADAPTERS and both real continuations", async () => { + const raw = await Bun.file( join(import.meta.dir, "..", "src", "cli", "repl.ts") ).text(); - // resolveGreenfieldStack appears TWICE — its definition AND its call in the line handler - // (so a definition with no call would fail this). - expect( - (src.match(/resolveGreenfieldStack\(/g) ?? []).length - ).toBeGreaterThanOrEqual(2); - // A resolved stack routes into planning, not the normal send path. - expect(src).toContain("runGreenfieldPlanning("); - // …and the planner constraints come from the resolved adapter via greenfieldConstraints. - expect(src).toContain("greenfieldConstraints(stack, echo)"); - // The registry the dispatch resolves against is the composition-root adapter list. - expect(src).toContain("STACK_ADAPTERS"); + // Strip comments so a matching phrase in prose can't satisfy the guard. + const src = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); + + // One bound match: greenfieldOrSend(args.dir, STACK_ADAPTERS, … runGreenfieldPlanning … + // runSend(line) …). The order (planning continuation before the send continuation) and + // the shared call mean a define-but-never-call, wrong registry, or swapped branch fails. + const wired = + /greenfieldOrSend\(\s*args\.dir,\s*STACK_ADAPTERS,[\s\S]*?runGreenfieldPlanning\([\s\S]*?runSend\(line\)[\s\S]*?\)/; + + expect(src).toMatch(wired); }); }); From 227c97b950a84dfcdebd5a4688fbc79ca55e2277 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 01:26:18 +0200 Subject: [PATCH 24/53] test(core): exactly-once cardinality + single-statement-bound greenfield source guard WS2 panel r4 residuals (both agreement-1, on repl-greenfield-stack.test.ts): - minor/missing-test: the branch tests proved exclusivity but not exactly-once cardinality (calling the selected continuation twice would still pass). Count invocations via a spy and assert onGreenfield/onSend fire EXACTLY once. - major/scope-bypass: the source-guard regex used [\s\S]*? which could cross the greenfieldOrSend call boundary, so a later unconditional runSend(line) would still satisfy it. Bound the match with [^;] (cannot cross the call's terminating ;) and require the onSend arrow form '() => runSend(line)' INSIDE the call, plus a defense-in-depth assertion that no bare 'await runSend(line);' trails right before the handler closes. validate green (3258 pass, 0 fail). --- .../core/tests/repl-greenfield-stack.test.ts | 111 ++++++++++-------- 1 file changed, 60 insertions(+), 51 deletions(-) diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index cc5fc255..f59f573e 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -100,79 +100,81 @@ describe("greenfieldConstraints (resolved adapter supplies the constraints)", () // unmatched/already-planned project falls through to the normal send — without driving the // whole REPL. EXACTLY ONE continuation runs. describe("greenfieldOrSend (the interception branch)", () => { - test("a detected+unplanned project runs onGreenfield with the EXACT stack, never onSend", async () => { - const detected = stub("boringstack", true); - const captured: { stack: IStackAdapter | null; sent: boolean } = { - stack: null, - sent: false, + /** Counts each continuation's invocations, so tests assert exactly-once cardinality (not just + * exclusivity — calling the selected branch twice must also fail). Records the stack the + * planning branch received. */ + const spy = (): { + greenfield: number; + send: number; + stack: IStackAdapter | null; + onGreenfield: (s: IStackAdapter) => Promise; + onSend: () => Promise; + } => { + const s = { + greenfield: 0, + send: 0, + stack: null as IStackAdapter | null, + onGreenfield: (adapter: IStackAdapter): Promise => { + s.greenfield += 1; + s.stack = adapter; + + return Promise.resolve(); + }, + onSend: (): Promise => { + s.send += 1; + + return Promise.resolve(); + }, }; + return s; + }; + + test("a detected+unplanned project runs onGreenfield EXACTLY once with the EXACT stack, never onSend", async () => { + const detected = stub("boringstack", true); + const s = spy(); + await greenfieldOrSend( "/dir", [stub("other", false), detected], noApprovedPlan, - (s) => { - captured.stack = s; - - return Promise.resolve(); - }, - () => { - captured.sent = true; - - return Promise.resolve(); - } + s.onGreenfield, + s.onSend ); - expect(captured.stack).toBe(detected); - expect(captured.sent).toBe(false); + expect(s.greenfield).toBe(1); + expect(s.send).toBe(0); + expect(s.stack).toBe(detected); }); - test("an undetected project runs onSend, never onGreenfield", async () => { - let planned = false; - let sent = false; + test("an undetected project runs onSend EXACTLY once, never onGreenfield", async () => { + const s = spy(); await greenfieldOrSend( "/dir", [stub("a", false)], noApprovedPlan, - () => { - planned = true; - - return Promise.resolve(); - }, - () => { - sent = true; - - return Promise.resolve(); - } + s.onGreenfield, + s.onSend ); - expect(sent).toBe(true); - expect(planned).toBe(false); + expect(s.send).toBe(1); + expect(s.greenfield).toBe(0); }); - test("a detected but ALREADY-planned project runs onSend, never onGreenfield", async () => { - let planned = false; - let sent = false; + test("a detected but ALREADY-planned project runs onSend EXACTLY once, never onGreenfield", async () => { + const s = spy(); await greenfieldOrSend( "/dir", [stub("boringstack", true)], hasApprovedPlan, - () => { - planned = true; - - return Promise.resolve(); - }, - () => { - sent = true; - - return Promise.resolve(); - } + s.onGreenfield, + s.onSend ); - expect(sent).toBe(true); - expect(planned).toBe(false); + expect(s.send).toBe(1); + expect(s.greenfield).toBe(0); }); }); @@ -193,12 +195,19 @@ describe("the REPL line handler wires greenfieldOrSend (source guard)", () => { // Strip comments so a matching phrase in prose can't satisfy the guard. const src = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); - // One bound match: greenfieldOrSend(args.dir, STACK_ADAPTERS, … runGreenfieldPlanning … - // runSend(line) …). The order (planning continuation before the send continuation) and - // the shared call mean a define-but-never-call, wrong registry, or swapped branch fails. + // A SINGLE-STATEMENT bound match: `[^;]` cannot cross the `);` that ends the + // greenfieldOrSend call, so a later unconditional `runSend(line)` statement (the bypass + // where planning THEN sends) can't satisfy the guard — runSend(line) must be INSIDE this + // call, as the onSend arrow continuation. The registry is bound into the call and the + // planning continuation must precede the send continuation. const wired = - /greenfieldOrSend\(\s*args\.dir,\s*STACK_ADAPTERS,[\s\S]*?runGreenfieldPlanning\([\s\S]*?runSend\(line\)[\s\S]*?\)/; + /greenfieldOrSend\(\s*args\.dir,\s*STACK_ADAPTERS,[^;]*?runGreenfieldPlanning\([^;]*?\(\)\s*=>\s*runSend\(line\)[^;]*?\)/; expect(src).toMatch(wired); + + // Defense-in-depth against the "send AFTER planning" bypass: runSend(line) must not appear + // as a bare statement right before the handler closes (it belongs only as the onSend + // continuation). Guards the exact regression the reviewer described. + expect(src).not.toMatch(/;\s*await runSend\(line\);\s*\}/); }); }); From 21e8d79d6fc44deb1857a5757c4549aebcd6a0e0 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 01:35:29 +0200 Subject: [PATCH 25/53] test(core): drop the as-cast from the greenfield spy + remove overclaiming guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel r5 BLOCK: - major/as-cast (unanimous): the spy used 'null as IStackAdapter | null', violating the no-casts house rule (which applies in tests too). Give the spy an explicit ISpy interface and annotate 'const s: ISpy', so 'stack' is nullable with no cast. - minor/scope-bypass: the second source-guard assertion (not.toMatch of one exact ';await runSend(line);}' shape) did not actually close the plan-THEN-send hole — ASI/void/return/other forms slip past. Removed it rather than overclaim; the single-statement-bound positive regex + the behavioral branch test remain. validate green (3258 pass, 0 fail). --- .../core/tests/repl-greenfield-stack.test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index f59f573e..278e23d1 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -102,18 +102,21 @@ describe("greenfieldConstraints (resolved adapter supplies the constraints)", () describe("greenfieldOrSend (the interception branch)", () => { /** Counts each continuation's invocations, so tests assert exactly-once cardinality (not just * exclusivity — calling the selected branch twice must also fail). Records the stack the - * planning branch received. */ - const spy = (): { + * planning branch received. The explicit type annotation makes `stack` nullable without an + * `as` cast (house rule: no casts, incl. tests). */ + interface ISpy { greenfield: number; send: number; stack: IStackAdapter | null; onGreenfield: (s: IStackAdapter) => Promise; onSend: () => Promise; - } => { - const s = { + } + + const spy = (): ISpy => { + const s: ISpy = { greenfield: 0, send: 0, - stack: null as IStackAdapter | null, + stack: null, onGreenfield: (adapter: IStackAdapter): Promise => { s.greenfield += 1; s.stack = adapter; @@ -204,10 +207,5 @@ describe("the REPL line handler wires greenfieldOrSend (source guard)", () => { /greenfieldOrSend\(\s*args\.dir,\s*STACK_ADAPTERS,[^;]*?runGreenfieldPlanning\([^;]*?\(\)\s*=>\s*runSend\(line\)[^;]*?\)/; expect(src).toMatch(wired); - - // Defense-in-depth against the "send AFTER planning" bypass: runSend(line) must not appear - // as a bare statement right before the handler closes (it belongs only as the onSend - // continuation). Guards the exact regression the reviewer described. - expect(src).not.toMatch(/;\s*await runSend\(line\);\s*\}/); }); }); From 57871bab3bdb623f9372cd1aa986e494e040dd7a Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 01:45:02 +0200 Subject: [PATCH 26/53] test(core): strengthen the greenfield source guard to close both plan-then-send bypasses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel r6 residuals (agreement-1): deleting the negative assertion last round relaxed the source guard. Replace it with a STRONGER single structurally-bound regex that closes both demonstrated bypasses at the source level: - onGreenfield must be a BRACE-LESS arrow '(stack) => runGreenfieldPlanning(' — a single expression, so it cannot contain a trailing runSend (the ASI block-body 'stack => { plan; send }' bypass). - the call must be the handler's TERMINAL statement ') ; }' immediately after, so no bare 'runSend(line)' can trail it. Verified out-of-band: the regex matches the real wiring, and rejects BOTH a block-body plan-then-send and a trailing-send handler. Strengthens, not relaxes. validate green (3258 pass, 0 fail). --- .../core/tests/repl-greenfield-stack.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index 278e23d1..1aeb9b99 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -198,13 +198,19 @@ describe("the REPL line handler wires greenfieldOrSend (source guard)", () => { // Strip comments so a matching phrase in prose can't satisfy the guard. const src = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); - // A SINGLE-STATEMENT bound match: `[^;]` cannot cross the `);` that ends the - // greenfieldOrSend call, so a later unconditional `runSend(line)` statement (the bypass - // where planning THEN sends) can't satisfy the guard — runSend(line) must be INSIDE this - // call, as the onSend arrow continuation. The registry is bound into the call and the - // planning continuation must precede the send continuation. + // One structurally-bound match that closes BOTH plan-THEN-send bypasses at the source + // level (a strengthening, not a relaxation, of the deleted negative guard): + // • `[^;]` throughout — the match cannot cross the `);` that ends the call, so it stays + // inside a single statement. + // • onGreenfield is a BRACE-LESS arrow: `(stack) => runGreenfieldPlanning(` (no `{`), so + // it is a single expression and cannot itself contain a trailing `runSend(line)` + // (the ASI block-body bypass `stack => { plan; send }`). + // • onSend is `() => runSend(line)` and the call is the handler's TERMINAL statement — + // `) ; }` immediately after — so no bare `runSend(line)` can trail the call. + // A wrong registry, define-but-never-call, swapped branch, block-body plan-then-send, or a + // trailing send all break the single match. const wired = - /greenfieldOrSend\(\s*args\.dir,\s*STACK_ADAPTERS,[^;]*?runGreenfieldPlanning\([^;]*?\(\)\s*=>\s*runSend\(line\)[^;]*?\)/; + /greenfieldOrSend\(\s*args\.dir,\s*STACK_ADAPTERS,[^;]*?\(stack\)\s*=>\s*runGreenfieldPlanning\([^;]*?\(\)\s*=>\s*runSend\(line\)\s*\)\s*;\s*\}/; expect(src).toMatch(wired); }); From de6cab12e39d19d6b27bdbf945949f4e412a36fc Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 01:59:37 +0200 Subject: [PATCH 27/53] test(core): pin greenfield wiring shape + commit the negative bypass cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel r7 BLOCK (3 findings): - major/missing-test: the rejection of malicious forms was verified only out of band. Commit the negatives: three tests assert the same WIRED regex REJECTS a block-body plan-then-send, a .finally-chained send, and a trailing runSend. - major/scope-bypass: a brace-less arrow is not single-operation — runGreenfieldPlanning(...).finally(() => runSend(line)) chained past [^;]. Pin onGreenfield to the EXACT call terminated by '),' so nothing (no .finally) can sit between that ')' and the ',' — the chain form now fails (and is tested). - minor/other: comments overclaimed 'closes BOTH'. Restate honestly: this is a WIRING guard, not a purity proof; a regex can't prove a continuation is side-effect-free, but any deviation from the exact shape fails and forces reviewer attention. The greenfieldOrSend behavioral test is the semantic proof. validate green (3261 pass, 0 fail). --- .../core/tests/repl-greenfield-stack.test.ts | 70 ++++++++++++------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index 1aeb9b99..ac3f2f85 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -184,34 +184,56 @@ describe("greenfieldOrSend (the interception branch)", () => { // The remaining glue — the readline line handler CALLING greenfieldOrSend with the // composition-root registry and the two real continuations — lives inside repl()'s readline // closure, which is not unit-reachable (the same class the /clear + --continue wiring is -// source-guarded for; see repl-ask-user-wiring.test.ts). A SINGLE bound regex over -// comment-stripped source proves the pieces are wired TOGETHER (not independent presence -// probes): greenfieldOrSend(args.dir, STACK_ADAPTERS, , , ). Removing the interception or swapping a -// callback breaks the single match. +// source-guarded for; see repl-ask-user-wiring.test.ts). +// +// SCOPE, stated honestly: this is a WIRING guard, NOT a purity proof. A regex cannot prove a +// continuation is side-effect-free — a determined author can always chain a send (`.finally`, +// `.then`, comma operator, …). What it CAN do is pin the EXACT current wiring shape, so any +// deviation — block body, a send chained onto runGreenfieldPlanning(...), a trailing send, a +// changed registry or arg list — fails the match and forces a reviewer to look. The SEMANTIC +// proof that the branch plans XOR sends is the greenfieldOrSend behavioral test above; this +// only guards that the handler actually routes through that branch with the real callbacks. +// +// WIRED pins: greenfieldOrSend(args.dir, STACK_ADAPTERS, , onGreenfield being EXACTLY +// `(stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack)` +// terminated by `),` (so nothing — no `.finally(send)` — can sit between that `)` and the `,`), +// onSend `() => runSend(line)`, and the call as the handler's TERMINAL statement (`) ; }`). +const WIRED = + /greenfieldOrSend\(\s*args\.dir,\s*STACK_ADAPTERS,[^;]*?\(stack\)\s*=>\s*runGreenfieldPlanning\(\s*args\.dir,\s*line,\s*echo,\s*rl,\s*activeModelEntry,\s*stack\s*\)\s*,\s*\(\)\s*=>\s*runSend\(line\)\s*\)\s*;\s*\}/; + +/** Strip line + block comments so a matching phrase in prose can't satisfy the guard. */ +const stripComments = (s: string): string => + s.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); + describe("the REPL line handler wires greenfieldOrSend (source guard)", () => { - test("dispatch calls greenfieldOrSend with STACK_ADAPTERS and both real continuations", async () => { + test("the real handler source matches the exact wiring shape", async () => { const raw = await Bun.file( join(import.meta.dir, "..", "src", "cli", "repl.ts") ).text(); - // Strip comments so a matching phrase in prose can't satisfy the guard. - const src = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); - - // One structurally-bound match that closes BOTH plan-THEN-send bypasses at the source - // level (a strengthening, not a relaxation, of the deleted negative guard): - // • `[^;]` throughout — the match cannot cross the `);` that ends the call, so it stays - // inside a single statement. - // • onGreenfield is a BRACE-LESS arrow: `(stack) => runGreenfieldPlanning(` (no `{`), so - // it is a single expression and cannot itself contain a trailing `runSend(line)` - // (the ASI block-body bypass `stack => { plan; send }`). - // • onSend is `() => runSend(line)` and the call is the handler's TERMINAL statement — - // `) ; }` immediately after — so no bare `runSend(line)` can trail the call. - // A wrong registry, define-but-never-call, swapped branch, block-body plan-then-send, or a - // trailing send all break the single match. - const wired = - /greenfieldOrSend\(\s*args\.dir,\s*STACK_ADAPTERS,[^;]*?\(stack\)\s*=>\s*runGreenfieldPlanning\([^;]*?\(\)\s*=>\s*runSend\(line\)\s*\)\s*;\s*\}/; - - expect(src).toMatch(wired); + expect(stripComments(raw)).toMatch(WIRED); + }); + + // Regression-test the guard's NEGATIVE guarantees in-file (not just out of band): each + // plan-THEN-send bypass the reviewers raised must FAIL the same WIRED regex. + test("the guard rejects a block-body onGreenfield that plans then sends", () => { + const bypass = + "greenfieldOrSend( args.dir, STACK_ADAPTERS, h, (stack) => { runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack); runSend(line); }, () => runSend(line) ); }"; + + expect(stripComments(bypass)).not.toMatch(WIRED); + }); + + test("the guard rejects a send chained onto runGreenfieldPlanning (.finally)", () => { + const bypass = + "greenfieldOrSend( args.dir, STACK_ADAPTERS, h, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack).finally(() => runSend(line)), () => runSend(line) ); }"; + + expect(stripComments(bypass)).not.toMatch(WIRED); + }); + + test("the guard rejects a bare runSend(line) trailing the greenfieldOrSend call", () => { + const bypass = + "greenfieldOrSend( args.dir, STACK_ADAPTERS, h, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); await runSend(line); }"; + + expect(stripComments(bypass)).not.toMatch(WIRED); }); }); From ae3db828f8ba5b28e34ca98929cf2b6e42d47d6e Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 02:09:44 +0200 Subject: [PATCH 28/53] test(core): pin the ENTIRE greenfieldOrSend call shape (close the callback-purity arms race) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel r8 residual (agreement-1): a hasApprovedPlan callback like '() => runSend(line).then(() => false)' still matched because the third arg was unrestricted [^;]*?. Rather than continue allow-variation-but-forbid-sends (a regex can't prove any of the three callbacks is pure — the reviewers can always name another chained-send form), pin the WHOLE call to its exact current shape: all three arguments, not just the two continuations. Any deviation to any arg (a sending hasPlan, a chained/trailing send, a wrong registry) now fails the match and forces reviewer attention. Committed the raised form as a fourth negative case. Semantic plan-XOR-send remains proven by the greenfieldOrSend behavioral test. validate green (3262 pass, 0 fail). --- .../core/tests/repl-greenfield-stack.test.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index ac3f2f85..891f61fb 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -194,12 +194,18 @@ describe("greenfieldOrSend (the interception branch)", () => { // proof that the branch plans XOR sends is the greenfieldOrSend behavioral test above; this // only guards that the handler actually routes through that branch with the real callbacks. // -// WIRED pins: greenfieldOrSend(args.dir, STACK_ADAPTERS, , onGreenfield being EXACTLY -// `(stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack)` -// terminated by `),` (so nothing — no `.finally(send)` — can sit between that `)` and the `,`), -// onSend `() => runSend(line)`, and the call as the handler's TERMINAL statement (`) ; }`). +// WIRED pins the ENTIRE greenfieldOrSend call to its exact current shape — all three arguments, +// not just the two continuations. That is the honest endpoint for guarding a not-unit-reachable +// closure: rather than allow-variation-but-forbid-sends (a regex can't prove any callback is +// pure — a determined author chains a send onto ANY of the three), pin the exact wiring so ANY +// deviation (a changed hasPlan/onGreenfield/onSend, a chained or trailing send, a wrong registry) +// fails the match and forces a reviewer to look. Pins: greenfieldOrSend(args.dir, STACK_ADAPTERS, +// `async (d) => (await loadApprovedPlan(d)) !== null`, `(stack) => runGreenfieldPlanning(args.dir, +// line, echo, rl, activeModelEntry, stack)` terminated by `),`, `() => runSend(line)`, and the +// call as the handler's TERMINAL statement (`) ; }`). Semantic plan-XOR-send is proven by the +// greenfieldOrSend behavioral test above. const WIRED = - /greenfieldOrSend\(\s*args\.dir,\s*STACK_ADAPTERS,[^;]*?\(stack\)\s*=>\s*runGreenfieldPlanning\(\s*args\.dir,\s*line,\s*echo,\s*rl,\s*activeModelEntry,\s*stack\s*\)\s*,\s*\(\)\s*=>\s*runSend\(line\)\s*\)\s*;\s*\}/; + /greenfieldOrSend\(\s*args\.dir,\s*STACK_ADAPTERS,\s*async\s*\(d\)\s*=>\s*\(await loadApprovedPlan\(d\)\)\s*!==\s*null\s*,\s*\(stack\)\s*=>\s*runGreenfieldPlanning\(\s*args\.dir,\s*line,\s*echo,\s*rl,\s*activeModelEntry,\s*stack\s*\)\s*,\s*\(\)\s*=>\s*runSend\(line\)\s*\)\s*;\s*\}/; /** Strip line + block comments so a matching phrase in prose can't satisfy the guard. */ const stripComments = (s: string): string => @@ -236,4 +242,13 @@ describe("the REPL line handler wires greenfieldOrSend (source guard)", () => { expect(stripComments(bypass)).not.toMatch(WIRED); }); + + test("the guard rejects a hasApprovedPlan callback that sends (send-while-checking)", () => { + // The plan-state predicate is pinned to its exact pure form, so a hasApprovedPlan that + // sends — `() => runSend(line).then(() => false)` — no longer matches WIRED. + const bypass = + "greenfieldOrSend( args.dir, STACK_ADAPTERS, () => runSend(line).then(() => false), (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); }"; + + expect(stripComments(bypass)).not.toMatch(WIRED); + }); }); From 24d2c880e654b6ab1dab1576d69683c847789f8b Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 02:18:24 +0200 Subject: [PATCH 29/53] test(core): negatives isolate their bypass + anchor the callee (WS2 r9 fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel r9 residuals: - minor/wrong-idiom: after pinning the 3rd arg, the three pre-existing negatives (block-body, .finally, trailing) still used 'h' as hasApprovedPlan, so they failed for the WRONG reason (h ≠ the pinned form) instead of their specific bypass. Give each the REAL 'async (d) => (await loadApprovedPlan(d)) !== null' form so each isolates exactly the bypass it names. - major/scope-bypass (cheap part): a member-call decoy 'shim.greenfieldOrSend(' matched. Anchor the callee with '(? { // line, echo, rl, activeModelEntry, stack)` terminated by `),`, `() => runSend(line)`, and the // call as the handler's TERMINAL statement (`) ; }`). Semantic plan-XOR-send is proven by the // greenfieldOrSend behavioral test above. +// `(?\s*\(await loadApprovedPlan\(d\)\)\s*!==\s*null\s*,\s*\(stack\)\s*=>\s*runGreenfieldPlanning\(\s*args\.dir,\s*line,\s*echo,\s*rl,\s*activeModelEntry,\s*stack\s*\)\s*,\s*\(\)\s*=>\s*runSend\(line\)\s*\)\s*;\s*\}/; + /(?\s*\(await loadApprovedPlan\(d\)\)\s*!==\s*null\s*,\s*\(stack\)\s*=>\s*runGreenfieldPlanning\(\s*args\.dir,\s*line,\s*echo,\s*rl,\s*activeModelEntry,\s*stack\s*\)\s*,\s*\(\)\s*=>\s*runSend\(line\)\s*\)\s*;\s*\}/; /** Strip line + block comments so a matching phrase in prose can't satisfy the guard. */ const stripComments = (s: string): string => @@ -224,21 +228,21 @@ describe("the REPL line handler wires greenfieldOrSend (source guard)", () => { // plan-THEN-send bypass the reviewers raised must FAIL the same WIRED regex. test("the guard rejects a block-body onGreenfield that plans then sends", () => { const bypass = - "greenfieldOrSend( args.dir, STACK_ADAPTERS, h, (stack) => { runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack); runSend(line); }, () => runSend(line) ); }"; + "greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => { runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack); runSend(line); }, () => runSend(line) ); }"; expect(stripComments(bypass)).not.toMatch(WIRED); }); test("the guard rejects a send chained onto runGreenfieldPlanning (.finally)", () => { const bypass = - "greenfieldOrSend( args.dir, STACK_ADAPTERS, h, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack).finally(() => runSend(line)), () => runSend(line) ); }"; + "greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack).finally(() => runSend(line)), () => runSend(line) ); }"; expect(stripComments(bypass)).not.toMatch(WIRED); }); test("the guard rejects a bare runSend(line) trailing the greenfieldOrSend call", () => { const bypass = - "greenfieldOrSend( args.dir, STACK_ADAPTERS, h, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); await runSend(line); }"; + "greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); await runSend(line); }"; expect(stripComments(bypass)).not.toMatch(WIRED); }); @@ -251,4 +255,13 @@ describe("the REPL line handler wires greenfieldOrSend (source guard)", () => { expect(stripComments(bypass)).not.toMatch(WIRED); }); + + test("the guard rejects a member-call decoy (shim.greenfieldOrSend) via the callee anchor", () => { + // An otherwise-perfectly-shaped call on a DIFFERENT object must not satisfy the guard — + // the `(? (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); }"; + + expect(stripComments(decoy)).not.toMatch(WIRED); + }); }); From 2e315dec1d2018ed8faa721703bf837d964d4edc Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 02:27:33 +0200 Subject: [PATCH 30/53] test(core): anchor greenfield guard to 'await ' + strip template literals (WS2 r10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel r10 BLOCK (both source-guard findings, now concretely closed): - major/wrong-idiom: '(? { // line, echo, rl, activeModelEntry, stack)` terminated by `),`, `() => runSend(line)`, and the // call as the handler's TERMINAL statement (`) ; }`). Semantic plan-XOR-send is proven by the // greenfieldOrSend behavioral test above. -// `(?\s*\(await loadApprovedPlan\(d\)\)\s*!==\s*null\s*,\s*\(stack\)\s*=>\s*runGreenfieldPlanning\(\s*args\.dir,\s*line,\s*echo,\s*rl,\s*activeModelEntry,\s*stack\s*\)\s*,\s*\(\)\s*=>\s*runSend\(line\)\s*\)\s*;\s*\}/; + /(?<=await\s)greenfieldOrSend\(\s*args\.dir,\s*STACK_ADAPTERS,\s*async\s*\(d\)\s*=>\s*\(await loadApprovedPlan\(d\)\)\s*!==\s*null\s*,\s*\(stack\)\s*=>\s*runGreenfieldPlanning\(\s*args\.dir,\s*line,\s*echo,\s*rl,\s*activeModelEntry,\s*stack\s*\)\s*,\s*\(\)\s*=>\s*runSend\(line\)\s*\)\s*;\s*\}/; -/** Strip line + block comments so a matching phrase in prose can't satisfy the guard. */ -const stripComments = (s: string): string => - s.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +/** Reduce source to executable code the guard can match: strip block + line comments AND + * template literals, so a matching phrase in prose or a backtick decoy can't satisfy WIRED. */ +const codeOnly = (s: string): string => + s + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/[^\n]*/g, "") + .replace(/`(?:[^`\\]|\\.)*`/g, "``"); describe("the REPL line handler wires greenfieldOrSend (source guard)", () => { test("the real handler source matches the exact wiring shape", async () => { @@ -221,47 +228,61 @@ describe("the REPL line handler wires greenfieldOrSend (source guard)", () => { join(import.meta.dir, "..", "src", "cli", "repl.ts") ).text(); - expect(stripComments(raw)).toMatch(WIRED); + expect(codeOnly(raw)).toMatch(WIRED); }); // Regression-test the guard's NEGATIVE guarantees in-file (not just out of band): each // plan-THEN-send bypass the reviewers raised must FAIL the same WIRED regex. test("the guard rejects a block-body onGreenfield that plans then sends", () => { const bypass = - "greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => { runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack); runSend(line); }, () => runSend(line) ); }"; + "await greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => { runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack); runSend(line); }, () => runSend(line) ); }"; - expect(stripComments(bypass)).not.toMatch(WIRED); + expect(codeOnly(bypass)).not.toMatch(WIRED); }); test("the guard rejects a send chained onto runGreenfieldPlanning (.finally)", () => { const bypass = - "greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack).finally(() => runSend(line)), () => runSend(line) ); }"; + "await greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack).finally(() => runSend(line)), () => runSend(line) ); }"; - expect(stripComments(bypass)).not.toMatch(WIRED); + expect(codeOnly(bypass)).not.toMatch(WIRED); }); test("the guard rejects a bare runSend(line) trailing the greenfieldOrSend call", () => { const bypass = - "greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); await runSend(line); }"; + "await greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); await runSend(line); }"; - expect(stripComments(bypass)).not.toMatch(WIRED); + expect(codeOnly(bypass)).not.toMatch(WIRED); }); test("the guard rejects a hasApprovedPlan callback that sends (send-while-checking)", () => { // The plan-state predicate is pinned to its exact pure form, so a hasApprovedPlan that // sends — `() => runSend(line).then(() => false)` — no longer matches WIRED. const bypass = - "greenfieldOrSend( args.dir, STACK_ADAPTERS, () => runSend(line).then(() => false), (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); }"; + "await greenfieldOrSend( args.dir, STACK_ADAPTERS, () => runSend(line).then(() => false), (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); }"; - expect(stripComments(bypass)).not.toMatch(WIRED); + expect(codeOnly(bypass)).not.toMatch(WIRED); }); - test("the guard rejects a member-call decoy (shim.greenfieldOrSend) via the callee anchor", () => { - // An otherwise-perfectly-shaped call on a DIFFERENT object must not satisfy the guard — - // the `(? { + // The `(?<=await\s)` anchor requires the name IMMEDIATELY after `await ` — so any prefix + // (`.`, `$`, `#`, a Unicode id char) breaks it. A different callee cannot satisfy the guard. + for (const callee of [ + "shim.greenfieldOrSend", + "$greenfieldOrSend", + "this.#greenfieldOrSend", + ]) { + const decoy = `await ${callee}( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); }`; + + expect(codeOnly(decoy)).not.toMatch(WIRED); + } + }); + + test("the guard rejects an exact call hidden inside a template literal (stripped as non-code)", () => { + // A perfectly-shaped call sitting in a backtick string is NOT executable wiring; codeOnly + // strips template literals (like comments), so a template-literal decoy cannot satisfy WIRED. const decoy = - "shim.greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); }"; + "const x = `await greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); }`;"; - expect(stripComments(decoy)).not.toMatch(WIRED); + expect(codeOnly(decoy)).not.toMatch(WIRED); }); }); From 4ceeee24653b0b39b88097af700f7f8886973aa9 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 02:40:54 +0200 Subject: [PATCH 31/53] test(core): replace regex wiring guard with an ast-grep STRUCTURAL match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel r11 BLOCK (unanimous, agreement 4): a source-TEXT regex cannot distinguish executable code from a string / comment / template / unreachable copy of the same shape — codeOnly stripped comments+templates but an ordinary quoted string 'await greenfieldOrSend(...)' still satisfied WIRED. This is unfixable with regex; every stripping leaves another literal class. Fix the TOOL: match the AST structurally via ast-grep (the repo's own dependency, used by loop/astgrep-fix.ts). Pattern 'await greenfieldOrSend(args.dir, STACK_ADAPTERS, $$$REST)' matches ONLY a real call-expression node — a string/comment/template copy is not a call node and can never match (verified: against a file with string+comment+template+dead-block copies, ast-grep returns only the real call). Assert exactly one such node, and — since the matched region is real code — that it wires both continuations (runGreenfieldPlanning + runSend). A guard asserts ast-grep is present so it can't silently vanish. Plan-XOR-send semantics remain proven by the greenfieldOrSend behavioral test. validate green (3259 pass, 0 fail). --- .../core/tests/repl-greenfield-stack.test.ts | 163 ++++++++---------- 1 file changed, 69 insertions(+), 94 deletions(-) diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index ea38b6fc..7a357077 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -1,5 +1,6 @@ import { test, expect, describe } from "bun:test"; import { join } from "node:path"; +import { existsSync } from "node:fs"; import { resolveGreenfieldStack, greenfieldConstraints, @@ -182,107 +183,81 @@ describe("greenfieldOrSend (the interception branch)", () => { }); // The remaining glue — the readline line handler CALLING greenfieldOrSend with the -// composition-root registry and the two real continuations — lives inside repl()'s readline -// closure, which is not unit-reachable (the same class the /clear + --continue wiring is -// source-guarded for; see repl-ask-user-wiring.test.ts). -// -// SCOPE, stated honestly: this is a WIRING guard, NOT a purity proof. A regex cannot prove a -// continuation is side-effect-free — a determined author can always chain a send (`.finally`, -// `.then`, comma operator, …). What it CAN do is pin the EXACT current wiring shape, so any -// deviation — block body, a send chained onto runGreenfieldPlanning(...), a trailing send, a -// changed registry or arg list — fails the match and forces a reviewer to look. The SEMANTIC -// proof that the branch plans XOR sends is the greenfieldOrSend behavioral test above; this -// only guards that the handler actually routes through that branch with the real callbacks. -// -// WIRED pins the ENTIRE greenfieldOrSend call to its exact current shape — all three arguments, -// not just the two continuations. That is the honest endpoint for guarding a not-unit-reachable -// closure: rather than allow-variation-but-forbid-sends (a regex can't prove any callback is -// pure — a determined author chains a send onto ANY of the three), pin the exact wiring so ANY -// deviation (a changed hasPlan/onGreenfield/onSend, a chained or trailing send, a wrong registry) -// fails the match and forces a reviewer to look. Pins: greenfieldOrSend(args.dir, STACK_ADAPTERS, -// `async (d) => (await loadApprovedPlan(d)) !== null`, `(stack) => runGreenfieldPlanning(args.dir, -// line, echo, rl, activeModelEntry, stack)` terminated by `),`, `() => runSend(line)`, and the -// call as the handler's TERMINAL statement (`) ; }`). Semantic plan-XOR-send is proven by the -// greenfieldOrSend behavioral test above. -// `(?<=await\s)` anchors the callee to its exact STATEMENT form `await greenfieldOrSend(` — -// nothing may sit between `await ` and the name, so every prefixed-callee decoy is rejected: -// `shim.greenfieldOrSend`, `this.#greenfieldOrSend`, `$greenfieldOrSend`, `égreenfieldOrSend`, -// etc. (`.`, `#`, `$`, Unicode id chars all break the lookbehind). The only decoy a source regex -// still cannot exclude is the exact call sitting UNREACHABLE (a nested `if (false)` block) — that -// needs reachability analysis, not a regex; the greenfieldOrSend BEHAVIORAL test is the proof -// there. Template-literal copies are stripped below alongside comments. -const WIRED = - /(?<=await\s)greenfieldOrSend\(\s*args\.dir,\s*STACK_ADAPTERS,\s*async\s*\(d\)\s*=>\s*\(await loadApprovedPlan\(d\)\)\s*!==\s*null\s*,\s*\(stack\)\s*=>\s*runGreenfieldPlanning\(\s*args\.dir,\s*line,\s*echo,\s*rl,\s*activeModelEntry,\s*stack\s*\)\s*,\s*\(\)\s*=>\s*runSend\(line\)\s*\)\s*;\s*\}/; - -/** Reduce source to executable code the guard can match: strip block + line comments AND - * template literals, so a matching phrase in prose or a backtick decoy can't satisfy WIRED. */ -const codeOnly = (s: string): string => - s - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\/\/[^\n]*/g, "") - .replace(/`(?:[^`\\]|\\.)*`/g, "``"); - -describe("the REPL line handler wires greenfieldOrSend (source guard)", () => { - test("the real handler source matches the exact wiring shape", async () => { - const raw = await Bun.file( - join(import.meta.dir, "..", "src", "cli", "repl.ts") - ).text(); - - expect(codeOnly(raw)).toMatch(WIRED); - }); - - // Regression-test the guard's NEGATIVE guarantees in-file (not just out of band): each - // plan-THEN-send bypass the reviewers raised must FAIL the same WIRED regex. - test("the guard rejects a block-body onGreenfield that plans then sends", () => { - const bypass = - "await greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => { runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack); runSend(line); }, () => runSend(line) ); }"; +// composition-root registry and both continuations — lives inside repl()'s readline closure, +// which is not unit-reachable. A source-TEXT guard (regex over the file) cannot distinguish +// executable code from a string / comment / template / unreachable-block copy of the same shape; +// the reviewers demonstrated each such class in turn. So this guard matches the AST STRUCTURALLY +// via ast-grep (the repo's own tool — see loop/astgrep-fix.ts): the pattern matches ONLY a real +// `await greenfieldOrSend(...)` CALL-EXPRESSION node — a string/comment/template copy is simply +// not a call node and can never satisfy it. Semantic plan-XOR-send is proven by the +// greenfieldOrSend behavioral test above; this proves the handler routes through that branch +// with the real registry and continuations. +const AST_GREP = join( + import.meta.dir, + "..", + "..", + "..", + "node_modules", + ".bin", + "ast-grep" +); +const REPL_TS = join(import.meta.dir, "..", "src", "cli", "repl.ts"); + +/** The `text` of an ast-grep JSON match node, read without an `as` cast (house rule). */ +const matchText = (m: unknown): string => + m !== null && + typeof m === "object" && + "text" in m && + typeof m.text === "string" + ? m.text + : ""; + +/** Structurally match `pattern` over repl.ts via ast-grep, returning each matched code node's + * text. Because it matches the AST, string / comment / template / prose copies (which are NOT + * call-expression nodes) can never match — the class no source regex could exclude. */ +const astMatches = (pattern: string): string[] => { + const proc = Bun.spawnSync([ + AST_GREP, + "run", + "-p", + pattern, + "-l", + "ts", + "--json", + REPL_TS, + ]); + + if (proc.exitCode !== 0) { + throw new Error(`ast-grep failed: ${proc.stderr.toString()}`); + } - expect(codeOnly(bypass)).not.toMatch(WIRED); - }); + const parsed: unknown = JSON.parse(proc.stdout.toString()); - test("the guard rejects a send chained onto runGreenfieldPlanning (.finally)", () => { - const bypass = - "await greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack).finally(() => runSend(line)), () => runSend(line) ); }"; + return Array.isArray(parsed) ? parsed.map(matchText) : []; +}; - expect(codeOnly(bypass)).not.toMatch(WIRED); +describe("the REPL line handler wires greenfieldOrSend (ast-grep structural guard)", () => { + test("ast-grep is available — the guard must not silently vanish", () => { + expect(existsSync(AST_GREP)).toBe(true); }); - test("the guard rejects a bare runSend(line) trailing the greenfieldOrSend call", () => { - const bypass = - "await greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); await runSend(line); }"; - - expect(codeOnly(bypass)).not.toMatch(WIRED); - }); - - test("the guard rejects a hasApprovedPlan callback that sends (send-while-checking)", () => { - // The plan-state predicate is pinned to its exact pure form, so a hasApprovedPlan that - // sends — `() => runSend(line).then(() => false)` — no longer matches WIRED. - const bypass = - "await greenfieldOrSend( args.dir, STACK_ADAPTERS, () => runSend(line).then(() => false), (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); }"; - - expect(codeOnly(bypass)).not.toMatch(WIRED); - }); + test("exactly one real greenfieldOrSend(args.dir, STACK_ADAPTERS, …) CALL NODE is wired", () => { + const matches = astMatches( + "await greenfieldOrSend(args.dir, STACK_ADAPTERS, $$$REST)" + ); - test("the guard rejects prefixed-callee decoys (member call / $ / # / Unicode) via the await anchor", () => { - // The `(?<=await\s)` anchor requires the name IMMEDIATELY after `await ` — so any prefix - // (`.`, `$`, `#`, a Unicode id char) breaks it. A different callee cannot satisfy the guard. - for (const callee of [ - "shim.greenfieldOrSend", - "$greenfieldOrSend", - "this.#greenfieldOrSend", - ]) { - const decoy = `await ${callee}( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); }`; - - expect(codeOnly(decoy)).not.toMatch(WIRED); - } - }); + // Exactly ONE real call node. A string/comment/template copy is not a call node, so it can + // neither inflate this count nor stand in for a deleted real call (the whole decoy class the + // reviewers raised — string, comment, template — is closed at the AST level). + expect(matches.length).toBe(1); - test("the guard rejects an exact call hidden inside a template literal (stripped as non-code)", () => { - // A perfectly-shaped call sitting in a backtick string is NOT executable wiring; codeOnly - // strips template literals (like comments), so a template-literal decoy cannot satisfy WIRED. - const decoy = - "const x = `await greenfieldOrSend( args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line) ); }`;"; + // The matched region is REAL code, so text checks WITHIN it are immune to the decoy class: + // both continuations are wired — the branch plans via runGreenfieldPlanning and, for a + // non-detected/already-planned project, sends via runSend(line). Plan-XOR-send SEMANTICS are + // proven by the greenfieldOrSend behavioral test; this pins the real wiring. + const call = matches.join(""); - expect(codeOnly(decoy)).not.toMatch(WIRED); + expect(call).toContain("runGreenfieldPlanning("); + expect(call).toContain("runSend(line)"); }); }); From bc215eaa086ede49a34ccf0ce290f18912fb87b4 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 02:54:12 +0200 Subject: [PATCH 32/53] =?UTF-8?q?test(core):=20strict=20ast-grep=20wiring?= =?UTF-8?q?=20guard=20=E2=80=94=20exact=20shape=20+=20terminal=20check=20+?= =?UTF-8?q?=20AST=20negatives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel r13 BLOCK (3 legit findings; I had over-loosened the ast-grep guard): - scope-bypass: the $$$REST pattern constrained only the first two args, so plan-then-send bypasses still matched. Pin the FULL wiring as the ast-grep pattern (STRICT — all three args literal, no metavars): a sending hasPlan, a block-body onGreenfield, or a send chained onto planning is a DIFFERENT AST node and yields zero matches. - gate-relaxed: substring presence didn't pin shape. STRICT is the exact-shape node match; a trailing send (the only thing outside the call node) is closed by a terminal-statement check on the bytes after the match (range.byteOffset.end). - missing-test: re-added the negatives as AST-level regression tests — block-body, .finally chain, sending hasPlan, string/comment/template copies, trailing send — each run via ast-grep over a temp decoy file (0 matches, or fails the terminal check for trailing-send). Minors: toMatch fails closed on unexpected JSON (no ''-masking); dropped the weak existsSync check — astFind throws if ast-grep is absent (stdout isn't a JSON array) so the guard still can't vanish; handle ast-grep exit 1 = zero matches (not error). validate green (3263 pass, 0 fail). --- .../core/tests/repl-greenfield-stack.test.ts | 182 +++++++++++++----- 1 file changed, 139 insertions(+), 43 deletions(-) diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index 7a357077..a976fdb1 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -1,6 +1,7 @@ import { test, expect, describe } from "bun:test"; import { join } from "node:path"; -import { existsSync } from "node:fs"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { resolveGreenfieldStack, greenfieldConstraints, @@ -185,13 +186,15 @@ describe("greenfieldOrSend (the interception branch)", () => { // The remaining glue — the readline line handler CALLING greenfieldOrSend with the // composition-root registry and both continuations — lives inside repl()'s readline closure, // which is not unit-reachable. A source-TEXT guard (regex over the file) cannot distinguish -// executable code from a string / comment / template / unreachable-block copy of the same shape; -// the reviewers demonstrated each such class in turn. So this guard matches the AST STRUCTURALLY -// via ast-grep (the repo's own tool — see loop/astgrep-fix.ts): the pattern matches ONLY a real -// `await greenfieldOrSend(...)` CALL-EXPRESSION node — a string/comment/template copy is simply -// not a call node and can never satisfy it. Semantic plan-XOR-send is proven by the -// greenfieldOrSend behavioral test above; this proves the handler routes through that branch -// with the real registry and continuations. +// executable code from a string / comment / template / unreachable copy of the same shape. So +// this guard matches the AST STRUCTURALLY via ast-grep (the repo's own tool — see +// loop/astgrep-fix.ts): the pattern is the EXACT wiring, and ast-grep matches it only as a real +// call-expression node. This closes BOTH classes the reviewers raised: (a) code-vs-literal — a +// string/comment/template copy is not a call node, so it can never match; (b) shape bypasses — +// the pattern pins ALL THREE arguments, so a sending hasPlan, a block-body onGreenfield, or a +// send chained onto planning is a DIFFERENT AST node and fails. The one thing outside the call +// node is a trailing send AFTER it; the terminal-statement check closes that. Plan-XOR-send +// SEMANTICS are proven by the greenfieldOrSend behavioral test above. const AST_GREP = join( import.meta.dir, "..", @@ -203,19 +206,46 @@ const AST_GREP = join( ); const REPL_TS = join(import.meta.dir, "..", "src", "cli", "repl.ts"); -/** The `text` of an ast-grep JSON match node, read without an `as` cast (house rule). */ -const matchText = (m: unknown): string => - m !== null && - typeof m === "object" && - "text" in m && - typeof m.text === "string" - ? m.text - : ""; - -/** Structurally match `pattern` over repl.ts via ast-grep, returning each matched code node's - * text. Because it matches the AST, string / comment / template / prose copies (which are NOT - * call-expression nodes) can never match — the class no source regex could exclude. */ -const astMatches = (pattern: string): string[] => { +// The EXACT wiring, as an ast-grep pattern (all args pinned, no metavariables). A match is a +// real call-expression node with this precise shape. +const STRICT = + "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line))"; + +interface IMatch { + text: string; + endByte: number; +} + +/** Parse one ast-grep JSON match into {text, endByte}, FAILING CLOSED on any unexpected shape + * (never masking a schema change as an empty match). No `as` casts (house rule). */ +const toMatch = (m: unknown): IMatch => { + if ( + m !== null && + typeof m === "object" && + "text" in m && + typeof m.text === "string" && + "range" in m && + m.range !== null && + typeof m.range === "object" && + "byteOffset" in m.range && + m.range.byteOffset !== null && + typeof m.range.byteOffset === "object" && + "end" in m.range.byteOffset && + typeof m.range.byteOffset.end === "number" + ) { + return { text: m.text, endByte: m.range.byteOffset.end }; + } + + throw new Error( + "ast-grep match missing string `text` / numeric range.byteOffset.end" + ); +}; + +/** Structurally match `pattern` over `file` via ast-grep. ast-grep exits 0 when it finds + * matches and 1 when it finds NONE (both print valid JSON — `[…]` / `[]`), so success is + * "stdout parses to a JSON array", not the exit code. Throws (never silently passes) if + * ast-grep is absent or errors — its stdout won't be a JSON array — so the guard cannot vanish. */ +const astFind = (pattern: string, file: string): IMatch[] => { const proc = Bun.spawnSync([ AST_GREP, "run", @@ -224,40 +254,106 @@ const astMatches = (pattern: string): string[] => { "-l", "ts", "--json", - REPL_TS, + file, ]); - if (proc.exitCode !== 0) { - throw new Error(`ast-grep failed: ${proc.stderr.toString()}`); + let parsed: unknown; + + try { + parsed = JSON.parse(proc.stdout.toString()); + } catch { + throw new Error( + `ast-grep produced no JSON (exit ${proc.exitCode}) on ${file}: ${proc.stderr.toString()}` + ); + } + + if (!Array.isArray(parsed)) { + throw new Error(`ast-grep did not return a JSON array on ${file}`); } - const parsed: unknown = JSON.parse(proc.stdout.toString()); + return parsed.map(toMatch); +}; + +/** Run STRICT over an arbitrary source string (via a temp file), for negative decoys. */ +const strictOn = async (source: string): Promise => { + const dir = await mkdtemp(join(tmpdir(), "tsforge-guard-")); + + try { + const file = join(dir, "decoy.ts"); + + await writeFile(file, source); - return Array.isArray(parsed) ? parsed.map(matchText) : []; + return astFind(STRICT, file); + } finally { + await rm(dir, { recursive: true, force: true }); + } }; +/** True iff the greenfieldOrSend call ending at `endByte` is a TERMINAL statement — only `;` + * and the closing `}` follow. A trailing `await runSend(line)` (plan-THEN-send) breaks this. */ +const isTerminalStatement = (source: string, endByte: number): boolean => + /^\s*;\s*\}/.test( + Buffer.from(source, "utf8").subarray(endByte).toString("utf8") + ); + describe("the REPL line handler wires greenfieldOrSend (ast-grep structural guard)", () => { - test("ast-grep is available — the guard must not silently vanish", () => { - expect(existsSync(AST_GREP)).toBe(true); + test("exactly one call node with the EXACT wiring, as the terminal statement", async () => { + const src = await Bun.file(REPL_TS).text(); + const matches = astFind(STRICT, REPL_TS); + + // Exactly ONE real call node matching the full pinned shape — pins the registry, the + // hasPlan predicate, and both continuations at the AST level. + expect(matches.length).toBe(1); + + // …and it is the handler's terminal statement, so nothing sends after it (plan-THEN-send). + expect(isTerminalStatement(src, matches[0]?.endByte ?? -1)).toBe(true); }); - test("exactly one real greenfieldOrSend(args.dir, STACK_ADAPTERS, …) CALL NODE is wired", () => { - const matches = astMatches( - "await greenfieldOrSend(args.dir, STACK_ADAPTERS, $$$REST)" - ); + // NEGATIVE regression tests — each plan-THEN-send / decoy the reviewers raised must yield ZERO + // STRICT matches (or fail the terminal check), proving the guard actually rejects it. + const wrap = (call: string): string => + `async function h() {\n ${call};\n}\n`; + const CORRECT_CALL = + "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line))"; - // Exactly ONE real call node. A string/comment/template copy is not a call node, so it can - // neither inflate this count nor stand in for a deleted real call (the whole decoy class the - // reviewers raised — string, comment, template — is closed at the AST level). - expect(matches.length).toBe(1); + test("rejects a block-body onGreenfield that plans then sends (different AST node)", async () => { + const bypass = + "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => { runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack); runSend(line); }, () => runSend(line))"; + + expect((await strictOn(wrap(bypass))).length).toBe(0); + }); + + test("rejects a send chained onto runGreenfieldPlanning (.finally)", async () => { + const bypass = + "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack).finally(() => runSend(line)), () => runSend(line))"; + + expect((await strictOn(wrap(bypass))).length).toBe(0); + }); - // The matched region is REAL code, so text checks WITHIN it are immune to the decoy class: - // both continuations are wired — the branch plans via runGreenfieldPlanning and, for a - // non-detected/already-planned project, sends via runSend(line). Plan-XOR-send SEMANTICS are - // proven by the greenfieldOrSend behavioral test; this pins the real wiring. - const call = matches.join(""); + test("rejects a hasApprovedPlan predicate that sends (different AST node)", async () => { + const bypass = + "await greenfieldOrSend(args.dir, STACK_ADAPTERS, () => runSend(line).then(() => false), (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line))"; - expect(call).toContain("runGreenfieldPlanning("); - expect(call).toContain("runSend(line)"); + expect((await strictOn(wrap(bypass))).length).toBe(0); + }); + + test("rejects string / comment / template copies of the exact call (not call nodes)", async () => { + const asString = `const a = ${JSON.stringify(CORRECT_CALL)};`; + const asComment = `// ${CORRECT_CALL}`; + const asTemplate = "const b = `" + CORRECT_CALL + "`;"; + + expect((await strictOn(asString)).length).toBe(0); + expect((await strictOn(asComment)).length).toBe(0); + expect((await strictOn(asTemplate)).length).toBe(0); + }); + + test("rejects a trailing runSend after the correct call (fails the terminal check)", async () => { + const src = `async function h() {\n ${CORRECT_CALL};\n await runSend(line);\n}\n`; + const matches = await strictOn(src); + + // The call node itself still matches (it IS correct) … + expect(matches.length).toBe(1); + // … but it is NOT the terminal statement — a send follows — so the guard rejects it here. + expect(isTerminalStatement(src, matches[0]?.endByte ?? -1)).toBe(false); }); }); From 77fa3a15622d23eab4484939a0fb50aabde30801 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 03:07:22 +0200 Subject: [PATCH 33/53] test(core): anchor handler-terminal check to the runLine arrow (close nested-block class) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel r14 BLOCK: - scope-bypass: the terminal check matched only ';}' after the call, so an exact call in an inner dead/try block with an OUTER send passed while the handler still sends. Anchor it to the runLine HANDLER ARROW: require ';' '}' ';' after the call (statement terminator + arrow body close + the 'const runLine = … => {…}' assignment semicolon). A call nested in an inner block is followed by '}' + MORE code, not '};', so it fails — closing if(false){call} outer-send and try{call}finally{send}. - missing-test: added those exact decoys as committed negatives (STRICT matches the correct call node, isHandlerTerminal rejects it) plus a positive sanity that the correct arrow shape passes. - dead-code: dropped the unused IMatch.text field (only endByte is read now). validate green (3266 pass, 0 fail). --- .../core/tests/repl-greenfield-stack.test.ts | 74 ++++++++++++++----- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index a976fdb1..706fba45 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -212,18 +212,15 @@ const STRICT = "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line))"; interface IMatch { - text: string; endByte: number; } -/** Parse one ast-grep JSON match into {text, endByte}, FAILING CLOSED on any unexpected shape +/** The end byte offset of one ast-grep JSON match, FAILING CLOSED on any unexpected shape * (never masking a schema change as an empty match). No `as` casts (house rule). */ const toMatch = (m: unknown): IMatch => { if ( m !== null && typeof m === "object" && - "text" in m && - typeof m.text === "string" && "range" in m && m.range !== null && typeof m.range === "object" && @@ -233,12 +230,10 @@ const toMatch = (m: unknown): IMatch => { "end" in m.range.byteOffset && typeof m.range.byteOffset.end === "number" ) { - return { text: m.text, endByte: m.range.byteOffset.end }; + return { endByte: m.range.byteOffset.end }; } - throw new Error( - "ast-grep match missing string `text` / numeric range.byteOffset.end" - ); + throw new Error("ast-grep match missing numeric range.byteOffset.end"); }; /** Structurally match `pattern` over `file` via ast-grep. ast-grep exits 0 when it finds @@ -289,10 +284,15 @@ const strictOn = async (source: string): Promise => { } }; -/** True iff the greenfieldOrSend call ending at `endByte` is a TERMINAL statement — only `;` - * and the closing `}` follow. A trailing `await runSend(line)` (plan-THEN-send) breaks this. */ -const isTerminalStatement = (source: string, endByte: number): boolean => - /^\s*;\s*\}/.test( +/** True iff the greenfieldOrSend call ending at `endByte` is the terminal statement of the + * runLine HANDLER ARROW — followed by only `;` `}` `;` (statement terminator, the arrow body's + * closing brace, and the `const runLine = … => {…}` assignment's semicolon). Anchoring to the + * handler close rejects not just a sequential trailing send but a call hidden in an INNER block + * that still sends in the outer handler: `if (false) { call; } await runSend(line);` and + * `try { call; } finally { runSend(line); }` are each followed by `}` + MORE code (not `};`), so + * they fail — closing the nested/unreachable-block class. */ +const isHandlerTerminal = (source: string, endByte: number): boolean => + /^\s*;\s*\}\s*;/.test( Buffer.from(source, "utf8").subarray(endByte).toString("utf8") ); @@ -305,14 +305,18 @@ describe("the REPL line handler wires greenfieldOrSend (ast-grep structural guar // hasPlan predicate, and both continuations at the AST level. expect(matches.length).toBe(1); - // …and it is the handler's terminal statement, so nothing sends after it (plan-THEN-send). - expect(isTerminalStatement(src, matches[0]?.endByte ?? -1)).toBe(true); + // …and it is the terminal statement of the runLine handler arrow, so nothing sends after it. + expect(isHandlerTerminal(src, matches[0]?.endByte ?? -1)).toBe(true); }); // NEGATIVE regression tests — each plan-THEN-send / decoy the reviewers raised must yield ZERO - // STRICT matches (or fail the terminal check), proving the guard actually rejects it. + // STRICT matches (or fail the handler-terminal check), proving the guard actually rejects it. const wrap = (call: string): string => `async function h() {\n ${call};\n}\n`; + // The real handler's shape (`const runLine = … => {…};`), so the handler-terminal check can be + // exercised against enclosing-scope decoys exactly as it runs against real repl.ts. + const wrapArrow = (body: string): string => + `const runLine = async (line: string): Promise => {\n ${body}\n};\n`; const CORRECT_CALL = "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line))"; @@ -347,13 +351,43 @@ describe("the REPL line handler wires greenfieldOrSend (ast-grep structural guar expect((await strictOn(asTemplate)).length).toBe(0); }); - test("rejects a trailing runSend after the correct call (fails the terminal check)", async () => { - const src = `async function h() {\n ${CORRECT_CALL};\n await runSend(line);\n}\n`; + // Enclosing-scope decoys: STRICT still matches the (correct) call node, but the call is NOT the + // handler arrow's terminal statement — a send happens elsewhere in the handler — so + // isHandlerTerminal rejects each. Sanity: the correct arrow shape PASSES the terminal check. + test("the handler-terminal check passes for the correct arrow shape", async () => { + const src = wrapArrow(`${CORRECT_CALL};`); + const matches = await strictOn(src); + + expect(matches.length).toBe(1); + expect(isHandlerTerminal(src, matches[0]?.endByte ?? -1)).toBe(true); + }); + + test("rejects a sequential trailing runSend after the correct call", async () => { + const src = wrapArrow(`${CORRECT_CALL};\n await runSend(line);`); + const matches = await strictOn(src); + + expect(matches.length).toBe(1); + expect(isHandlerTerminal(src, matches[0]?.endByte ?? -1)).toBe(false); + }); + + test("rejects the correct call in an inner dead block with an outer send", async () => { + const src = wrapArrow( + `if (false) { ${CORRECT_CALL}; }\n await runSend(line);` + ); + const matches = await strictOn(src); + + // The call node still matches — but it is nested, not the handler's terminal statement. + expect(matches.length).toBe(1); + expect(isHandlerTerminal(src, matches[0]?.endByte ?? -1)).toBe(false); + }); + + test("rejects the correct call in a try whose finally sends", async () => { + const src = wrapArrow( + `try { ${CORRECT_CALL}; } finally { await runSend(line); }` + ); const matches = await strictOn(src); - // The call node itself still matches (it IS correct) … expect(matches.length).toBe(1); - // … but it is NOT the terminal statement — a send follows — so the guard rejects it here. - expect(isTerminalStatement(src, matches[0]?.endByte ?? -1)).toBe(false); + expect(isHandlerTerminal(src, matches[0]?.endByte ?? -1)).toBe(false); }); }); From d07ec573e3179afc9abee89c278de7c0e9755ff3 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 03:20:53 +0200 Subject: [PATCH 34/53] test(core): anchor greenfield guard to the runLine arrow's LAST statement (close reachability) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel r15 BLOCK (agreement-3): the byte-suffix terminal check couldn't prove the '}' closed the runLine arrow — 'await runSend(line); if (false) { CALL; };' passed it (the '}' closed the if, the ';' was an empty statement). A byte regex fundamentally can't anchor to the handler. Replace it with ONE ast-grep structural pattern that pins the call as the FINAL statement of the runLine arrow, matched by its exact signature: async (line: string): Promise => { $$$BODY ; } '$$$BODY' absorbs every earlier statement; the call must be the block's last node. This single structural fact closes ALL classes at the AST level — verified by committed negatives that each return 0: shape bypasses (block-body onGreenfield, .finally chain, sending hasPlan), code-vs-literal (string/comment/template), and reachability/nesting (trailing send, dead-block+outer-send, the r15 outer-send- before-dead-block bypass, try/finally, inner-arrow). Positive + a sanity test confirm the real handler and the correct arrow shape match (1). Dropped the byte-offset machinery (IMatch/toMatch/isHandlerTerminal) entirely — the guard now just counts matches. validate green (3268 pass, 0 fail). --- .../core/tests/repl-greenfield-stack.test.ts | 192 +++++++----------- 1 file changed, 75 insertions(+), 117 deletions(-) diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index 706fba45..4a18698a 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -186,15 +186,18 @@ describe("greenfieldOrSend (the interception branch)", () => { // The remaining glue — the readline line handler CALLING greenfieldOrSend with the // composition-root registry and both continuations — lives inside repl()'s readline closure, // which is not unit-reachable. A source-TEXT guard (regex over the file) cannot distinguish -// executable code from a string / comment / template / unreachable copy of the same shape. So -// this guard matches the AST STRUCTURALLY via ast-grep (the repo's own tool — see -// loop/astgrep-fix.ts): the pattern is the EXACT wiring, and ast-grep matches it only as a real -// call-expression node. This closes BOTH classes the reviewers raised: (a) code-vs-literal — a -// string/comment/template copy is not a call node, so it can never match; (b) shape bypasses — -// the pattern pins ALL THREE arguments, so a sending hasPlan, a block-body onGreenfield, or a -// send chained onto planning is a DIFFERENT AST node and fails. The one thing outside the call -// node is a trailing send AFTER it; the terminal-statement check closes that. Plan-XOR-send -// SEMANTICS are proven by the greenfieldOrSend behavioral test above. +// executable code from a string/comment/template copy, nor prove the call is reachable rather +// than nested in a dead block. So this guard matches the AST STRUCTURALLY via ast-grep (the +// repo's own tool — see loop/astgrep-fix.ts) with ONE pattern that pins the call as the FINAL +// statement of the runLine handler arrow (matched by its exact signature). That single structural +// fact closes every class the reviewers raised: +// - code-vs-literal: a string/comment/template copy is not a call node → no match; +// - shape bypasses: all three args are pinned, so a sending hasPlan / block-body onGreenfield / +// send chained onto planning is a DIFFERENT node → no match; +// - reachability/nesting: the call must be the arrow body's LAST statement, so a call in an +// inner if/try/arrow, or with ANY statement after it in the handler (a trailing send), is not +// the last statement → no match. (Verified against real repl.ts and each decoy.) +// Plan-XOR-send SEMANTICS are proven by the greenfieldOrSend behavioral test above. const AST_GREP = join( import.meta.dir, "..", @@ -206,41 +209,16 @@ const AST_GREP = join( ); const REPL_TS = join(import.meta.dir, "..", "src", "cli", "repl.ts"); -// The EXACT wiring, as an ast-grep pattern (all args pinned, no metavariables). A match is a -// real call-expression node with this precise shape. -const STRICT = - "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line))"; - -interface IMatch { - endByte: number; -} - -/** The end byte offset of one ast-grep JSON match, FAILING CLOSED on any unexpected shape - * (never masking a schema change as an empty match). No `as` casts (house rule). */ -const toMatch = (m: unknown): IMatch => { - if ( - m !== null && - typeof m === "object" && - "range" in m && - m.range !== null && - typeof m.range === "object" && - "byteOffset" in m.range && - m.range.byteOffset !== null && - typeof m.range.byteOffset === "object" && - "end" in m.range.byteOffset && - typeof m.range.byteOffset.end === "number" - ) { - return { endByte: m.range.byteOffset.end }; - } - - throw new Error("ast-grep match missing numeric range.byteOffset.end"); -}; +// The exact wiring, pinned as the LAST statement (`$$$BODY` absorbs everything before it) of the +// runLine arrow — identified by its exact signature. All args are literal (no metavariables). +const ANCHORED = + "async (line: string): Promise => { $$$BODY await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line)); }"; -/** Structurally match `pattern` over `file` via ast-grep. ast-grep exits 0 when it finds - * matches and 1 when it finds NONE (both print valid JSON — `[…]` / `[]`), so success is - * "stdout parses to a JSON array", not the exit code. Throws (never silently passes) if - * ast-grep is absent or errors — its stdout won't be a JSON array — so the guard cannot vanish. */ -const astFind = (pattern: string, file: string): IMatch[] => { +/** Count structural matches of `pattern` over `file` via ast-grep. ast-grep exits 0 with matches + * and 1 with none (both print valid JSON — `[…]` / `[]`), so success is "stdout parses to a JSON + * array", not the exit code. Throws (never silently passes) if ast-grep is absent or errors — its + * stdout won't be a JSON array — so the guard cannot silently vanish. */ +const countMatches = (pattern: string, file: string): number => { const proc = Bun.spawnSync([ AST_GREP, "run", @@ -266,11 +244,11 @@ const astFind = (pattern: string, file: string): IMatch[] => { throw new Error(`ast-grep did not return a JSON array on ${file}`); } - return parsed.map(toMatch); + return parsed.length; }; -/** Run STRICT over an arbitrary source string (via a temp file), for negative decoys. */ -const strictOn = async (source: string): Promise => { +/** Count ANCHORED matches over an arbitrary source string (via a temp file), for decoys. */ +const countOn = async (source: string): Promise => { const dir = await mkdtemp(join(tmpdir(), "tsforge-guard-")); try { @@ -278,116 +256,96 @@ const strictOn = async (source: string): Promise => { await writeFile(file, source); - return astFind(STRICT, file); + return countMatches(ANCHORED, file); } finally { await rm(dir, { recursive: true, force: true }); } }; -/** True iff the greenfieldOrSend call ending at `endByte` is the terminal statement of the - * runLine HANDLER ARROW — followed by only `;` `}` `;` (statement terminator, the arrow body's - * closing brace, and the `const runLine = … => {…}` assignment's semicolon). Anchoring to the - * handler close rejects not just a sequential trailing send but a call hidden in an INNER block - * that still sends in the outer handler: `if (false) { call; } await runSend(line);` and - * `try { call; } finally { runSend(line); }` are each followed by `}` + MORE code (not `};`), so - * they fail — closing the nested/unreachable-block class. */ -const isHandlerTerminal = (source: string, endByte: number): boolean => - /^\s*;\s*\}\s*;/.test( - Buffer.from(source, "utf8").subarray(endByte).toString("utf8") - ); - describe("the REPL line handler wires greenfieldOrSend (ast-grep structural guard)", () => { - test("exactly one call node with the EXACT wiring, as the terminal statement", async () => { - const src = await Bun.file(REPL_TS).text(); - const matches = astFind(STRICT, REPL_TS); - - // Exactly ONE real call node matching the full pinned shape — pins the registry, the - // hasPlan predicate, and both continuations at the AST level. - expect(matches.length).toBe(1); - - // …and it is the terminal statement of the runLine handler arrow, so nothing sends after it. - expect(isHandlerTerminal(src, matches[0]?.endByte ?? -1)).toBe(true); + test("the real handler has exactly one greenfieldOrSend call as its arrow's LAST statement", () => { + expect(countMatches(ANCHORED, REPL_TS)).toBe(1); }); - // NEGATIVE regression tests — each plan-THEN-send / decoy the reviewers raised must yield ZERO - // STRICT matches (or fail the handler-terminal check), proving the guard actually rejects it. - const wrap = (call: string): string => - `async function h() {\n ${call};\n}\n`; - // The real handler's shape (`const runLine = … => {…};`), so the handler-terminal check can be - // exercised against enclosing-scope decoys exactly as it runs against real repl.ts. + // NEGATIVE regression tests — each decoy the reviewers raised must yield ZERO ANCHORED matches. + // Every decoy uses the REAL handler-arrow shape, so it is the exact context the guard runs in. const wrapArrow = (body: string): string => `const runLine = async (line: string): Promise => {\n ${body}\n};\n`; const CORRECT_CALL = "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line))"; - test("rejects a block-body onGreenfield that plans then sends (different AST node)", async () => { + test("SANITY: the correct arrow shape matches (so the negatives fail for the right reason)", async () => { + expect(await countOn(wrapArrow(`${CORRECT_CALL};`))).toBe(1); + }); + + // Shape bypasses — a different node for one of the three args, so ANCHORED never matches. + test("rejects a block-body onGreenfield that plans then sends", async () => { const bypass = "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => { runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack); runSend(line); }, () => runSend(line))"; - expect((await strictOn(wrap(bypass))).length).toBe(0); + expect(await countOn(wrapArrow(`${bypass};`))).toBe(0); }); test("rejects a send chained onto runGreenfieldPlanning (.finally)", async () => { const bypass = "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack).finally(() => runSend(line)), () => runSend(line))"; - expect((await strictOn(wrap(bypass))).length).toBe(0); + expect(await countOn(wrapArrow(`${bypass};`))).toBe(0); }); - test("rejects a hasApprovedPlan predicate that sends (different AST node)", async () => { + test("rejects a hasApprovedPlan predicate that sends", async () => { const bypass = "await greenfieldOrSend(args.dir, STACK_ADAPTERS, () => runSend(line).then(() => false), (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line))"; - expect((await strictOn(wrap(bypass))).length).toBe(0); + expect(await countOn(wrapArrow(`${bypass};`))).toBe(0); }); - test("rejects string / comment / template copies of the exact call (not call nodes)", async () => { - const asString = `const a = ${JSON.stringify(CORRECT_CALL)};`; - const asComment = `// ${CORRECT_CALL}`; - const asTemplate = "const b = `" + CORRECT_CALL + "`;"; - - expect((await strictOn(asString)).length).toBe(0); - expect((await strictOn(asComment)).length).toBe(0); - expect((await strictOn(asTemplate)).length).toBe(0); - }); - - // Enclosing-scope decoys: STRICT still matches the (correct) call node, but the call is NOT the - // handler arrow's terminal statement — a send happens elsewhere in the handler — so - // isHandlerTerminal rejects each. Sanity: the correct arrow shape PASSES the terminal check. - test("the handler-terminal check passes for the correct arrow shape", async () => { - const src = wrapArrow(`${CORRECT_CALL};`); - const matches = await strictOn(src); - - expect(matches.length).toBe(1); - expect(isHandlerTerminal(src, matches[0]?.endByte ?? -1)).toBe(true); + // Code-vs-literal — a copy in a string/comment/template is not a call node. + test("rejects string / comment / template copies of the exact call", async () => { + expect(await countOn(`const a = ${JSON.stringify(CORRECT_CALL)};`)).toBe(0); + expect(await countOn(`// ${CORRECT_CALL}`)).toBe(0); + expect(await countOn("const b = `" + CORRECT_CALL + "`;")).toBe(0); }); + // Reachability / nesting — the call is present but is NOT the arrow's last statement, so the + // handler still sends. Each is the class byte-level checks could not close. test("rejects a sequential trailing runSend after the correct call", async () => { - const src = wrapArrow(`${CORRECT_CALL};\n await runSend(line);`); - const matches = await strictOn(src); - - expect(matches.length).toBe(1); - expect(isHandlerTerminal(src, matches[0]?.endByte ?? -1)).toBe(false); + expect( + await countOn(wrapArrow(`${CORRECT_CALL};\n await runSend(line);`)) + ).toBe(0); }); test("rejects the correct call in an inner dead block with an outer send", async () => { - const src = wrapArrow( - `if (false) { ${CORRECT_CALL}; }\n await runSend(line);` - ); - const matches = await strictOn(src); + expect( + await countOn( + wrapArrow(`if (false) { ${CORRECT_CALL}; }\n await runSend(line);`) + ) + ).toBe(0); + }); - // The call node still matches — but it is nested, not the handler's terminal statement. - expect(matches.length).toBe(1); - expect(isHandlerTerminal(src, matches[0]?.endByte ?? -1)).toBe(false); + test("rejects an outer send BEFORE the correct call in an inner dead block (r15 bypass)", async () => { + expect( + await countOn( + wrapArrow(`await runSend(line);\n if (false) { ${CORRECT_CALL}; };`) + ) + ).toBe(0); }); test("rejects the correct call in a try whose finally sends", async () => { - const src = wrapArrow( - `try { ${CORRECT_CALL}; } finally { await runSend(line); }` - ); - const matches = await strictOn(src); + expect( + await countOn( + wrapArrow(`try { ${CORRECT_CALL}; } finally { await runSend(line); }`) + ) + ).toBe(0); + }); - expect(matches.length).toBe(1); - expect(isHandlerTerminal(src, matches[0]?.endByte ?? -1)).toBe(false); + test("rejects the correct call nested in an inner arrow with an outer send", async () => { + expect( + await countOn( + wrapArrow( + `const inner = async () => { ${CORRECT_CALL}; };\n await runSend(line);` + ) + ) + ).toBe(0); }); }); From 7699344fd5e1451d81f645238ed39e25191bd571 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 03:33:06 +0200 Subject: [PATCH 35/53] test(core): scope the greenfield guard's claim to what it enforces (WS2 r16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel r16 (agreement-2 + two agreement-1, all the same point): the ANCHORED pattern proves the call is the runLine-arrow's LAST statement (nothing sends AFTER), but a send BEFORE it — 'await runSend(line); ;' — still matches, and the comment over-claimed 'closes EVERY class'. The preceding-send class cannot be closed statically: the REAL handler legitimately has conditional runSend in earlier return-guarded branches (plan-discuss/approval), so a blanket 'reject any preceding send' would reject the real handler — separating an unconditional preceding send from a return-guarded one is control-flow analysis, not pattern matching. Fix the claim to match the guarantee: reword the comment to state exactly what the guard enforces (exact call node + exact shape + last statement of the signature arrow → no send after) and what it explicitly does NOT (no unconditional preceding send — covered by the greenfieldOrSend behavioral test + build/e2e). Add a test that PINS this boundary: the preceding-send decoy returns 1 (documented limit, not silent). All other negatives (shape, literal, after-nesting) remain 0. validate green. --- .../core/tests/repl-greenfield-stack.test.ts | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index 4a18698a..c224033f 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -185,19 +185,25 @@ describe("greenfieldOrSend (the interception branch)", () => { // The remaining glue — the readline line handler CALLING greenfieldOrSend with the // composition-root registry and both continuations — lives inside repl()'s readline closure, -// which is not unit-reachable. A source-TEXT guard (regex over the file) cannot distinguish -// executable code from a string/comment/template copy, nor prove the call is reachable rather -// than nested in a dead block. So this guard matches the AST STRUCTURALLY via ast-grep (the -// repo's own tool — see loop/astgrep-fix.ts) with ONE pattern that pins the call as the FINAL -// statement of the runLine handler arrow (matched by its exact signature). That single structural -// fact closes every class the reviewers raised: +// which is not unit-reachable. This guard matches the AST STRUCTURALLY via ast-grep (the repo's +// own tool — see loop/astgrep-fix.ts) with ONE pattern that pins the exact call as the FINAL +// statement of an `async (line: string): Promise => {…}` arrow (the runLine handler's +// signature). +// +// WHAT IT GUARANTEES (each verified by a committed negative below): // - code-vs-literal: a string/comment/template copy is not a call node → no match; -// - shape bypasses: all three args are pinned, so a sending hasPlan / block-body onGreenfield / +// - shape: all three args are pinned literal, so a sending hasPlan / block-body onGreenfield / // send chained onto planning is a DIFFERENT node → no match; -// - reachability/nesting: the call must be the arrow body's LAST statement, so a call in an -// inner if/try/arrow, or with ANY statement after it in the handler (a trailing send), is not -// the last statement → no match. (Verified against real repl.ts and each decoy.) -// Plan-XOR-send SEMANTICS are proven by the greenfieldOrSend behavioral test above. +// - nothing sends AFTER it: the call is the arrow body's LAST statement, so a call nested in an +// inner if/try/arrow, or with any statement after it (a trailing send), is not last → no match. +// +// WHAT IT DOES NOT (and cannot, statically) guarantee: that no UNCONDITIONAL send runs BEFORE the +// call. The real handler legitimately contains conditional `runSend(line)` in earlier branches +// that `return` first (plan-discuss/approval), so a blanket "reject any preceding send" would +// reject the real handler — telling an unconditional preceding send from a return-guarded one is +// control-flow analysis, not pattern matching. That routing/ordering property is what the +// greenfieldOrSend behavioral test (plan XOR send) and the real build/e2e path cover; this guard +// pins the wiring's shape and terminal position, not the handler's full control flow. const AST_GREP = join( import.meta.dir, "..", @@ -348,4 +354,15 @@ describe("the REPL line handler wires greenfieldOrSend (ast-grep structural guar ) ).toBe(0); }); + + // DOCUMENTED LIMIT (pinned so the boundary is explicit, not silent): a send BEFORE the call is + // NOT rejected — the call is still the arrow's last statement. This is inherent: the real + // handler has conditional runSend in earlier return-guarded branches, so a preceding send can't + // be blanket-rejected without control-flow analysis. Ordering/routing is covered by the + // greenfieldOrSend behavioral test + build/e2e, not this structural wiring guard. + test("does NOT reject a preceding send (documented static-analysis limit)", async () => { + expect( + await countOn(wrapArrow(`await runSend(line);\n ${CORRECT_CALL};`)) + ).toBe(1); + }); }); From 86a39ea76d4eac4aa5fc8e5597be4783dc311344 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 03:38:14 +0200 Subject: [PATCH 36/53] docs(test): attribute the preceding-send limit accurately (not to the behavioral test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS2 panel PASS with agreement-1 precision residual: the comment attributed the 'no unconditional preceding send' gap to the greenfieldOrSend behavioral test, but that test only proves the two continuations are mutually exclusive INSIDE greenfieldOrSend — it never sees a runSend the caller runs BEFORE invoking it. Reword both the guard comment and the documented-limit test: only the real build/e2e path (or full CFA) exercises a preceding send. Comment-only; reduces an overclaim. --- packages/core/tests/repl-greenfield-stack.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index c224033f..2254cc4a 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -201,9 +201,11 @@ describe("greenfieldOrSend (the interception branch)", () => { // call. The real handler legitimately contains conditional `runSend(line)` in earlier branches // that `return` first (plan-discuss/approval), so a blanket "reject any preceding send" would // reject the real handler — telling an unconditional preceding send from a return-guarded one is -// control-flow analysis, not pattern matching. That routing/ordering property is what the -// greenfieldOrSend behavioral test (plan XOR send) and the real build/e2e path cover; this guard -// pins the wiring's shape and terminal position, not the handler's full control flow. +// control-flow analysis, not pattern matching. NOTE the greenfieldOrSend behavioral test does NOT +// cover this either: it proves the two continuations are mutually exclusive INSIDE greenfieldOrSend, +// but never sees a `runSend(line)` the caller runs BEFORE invoking it. Only the real build/e2e path +// (which executes the actual handler) — or full control-flow analysis — exercises a preceding +// send. This guard pins the wiring's shape and terminal position, not the handler's control flow. const AST_GREP = join( import.meta.dir, "..", @@ -358,8 +360,9 @@ describe("the REPL line handler wires greenfieldOrSend (ast-grep structural guar // DOCUMENTED LIMIT (pinned so the boundary is explicit, not silent): a send BEFORE the call is // NOT rejected — the call is still the arrow's last statement. This is inherent: the real // handler has conditional runSend in earlier return-guarded branches, so a preceding send can't - // be blanket-rejected without control-flow analysis. Ordering/routing is covered by the - // greenfieldOrSend behavioral test + build/e2e, not this structural wiring guard. + // be blanket-rejected without control-flow analysis. It is NOT covered by the greenfieldOrSend + // behavioral test either (that test cannot see a send the caller runs before invoking it) — only + // the real build/e2e path, which runs the actual handler, exercises this ordering. test("does NOT reject a preceding send (documented static-analysis limit)", async () => { expect( await countOn(wrapArrow(`await runSend(line);\n ${CORRECT_CALL};`)) From 930ede04ee762d0e0fff008eca5d35dd5f9dc14b Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 04:11:15 +0200 Subject: [PATCH 37/53] refactor(core): reclaim the web-shaped plan spine behind a generic IPlanSchema seam (WS3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core plan spine no longer names a web UI shape. ISlice/IProductPlan are GENERIC over the UI-intent type; core validates the structural spine (product, entity, verification) and delegates the stack-specific UI to an injected IPlanSchema { system, example, validateUi, extraCheck }. Moved to the adapter (loop/boringstack/plan-extension.ts): IUiIntent, LAYOUT_ARCHETYPES, LayoutArchetype, IMPLEMENTED_LAYOUT_ARCHETYPES, the isUiIntent validator (isBoringstackUiIntent), the planner PLANNER_SYSTEM/PLANNER_EXAMPLE, the cross-slice '≤1 home' rule, the acceptance UI-field extractor, and the bundled boringstackPlanSchema. Core generics: plan-types (ISlice/IProductPlan/IPlanSchema), plan-store (isProductPlan/isSlice/parsePlan/readPlan/loadApprovedPlan/writePlan/serializePlan thread the schema), propose-plan (proposePlan takes the schema; prompt/example removed), run-planning (IPlanningDeps carries the schema), acceptance-spec (planToAcceptanceSpec takes a uiFields extractor; acceptance.types screens widened to string[]). Composition root (repl.ts) + scripts + build.ts pass boringstackPlanSchema. No behavior change for a boringstack build. validate green (3269 pass, 0 fail). --- packages/core/scripts/headless-build.ts | 5 +- packages/core/src/cli/repl.ts | 5 +- .../src/loop/acceptance/acceptance-spec.ts | 33 ++- .../src/loop/acceptance/acceptance.types.ts | 4 +- packages/core/src/loop/boringstack/build.ts | 36 ++- .../src/loop/boringstack/plan-extension.ts | 222 ++++++++++++++++++ .../src/loop/boringstack/refine-prompt.ts | 10 +- packages/core/src/loop/planning/plan-store.ts | 136 ++++------- packages/core/src/loop/planning/plan-types.ts | 74 +++--- .../core/src/loop/planning/propose-plan.ts | 129 ++-------- .../core/src/loop/planning/run-planning.ts | 14 +- packages/core/tests/acceptance-spec.test.ts | 61 +++-- packages/core/tests/boringstack-build.test.ts | 30 ++- .../tests/boringstack-e2e-generator.test.ts | 8 +- .../boringstack-final-acceptance.test.ts | 15 +- .../tests/boringstack-refine-prompt.test.ts | 9 +- .../tests/boringstack-testid-contract.test.ts | 8 +- packages/core/tests/plan-store.test.ts | 71 +++--- packages/core/tests/propose-plan.test.ts | 72 ++++-- .../core/tests/repl-greenfield-stack.test.ts | 8 +- packages/core/tests/run-planning.test.ts | 39 ++- 21 files changed, 599 insertions(+), 390 deletions(-) create mode 100644 packages/core/src/loop/boringstack/plan-extension.ts diff --git a/packages/core/scripts/headless-build.ts b/packages/core/scripts/headless-build.ts index 51d0d010..d98cfb87 100644 --- a/packages/core/scripts/headless-build.ts +++ b/packages/core/scripts/headless-build.ts @@ -22,6 +22,7 @@ import { detectContextWindow } from "../src/cli/model-setup"; import { renderEvent } from "../src/render"; import { logsDir } from "../src/session-store"; import { loadApprovedPlan, parsePlan } from "../src/loop/planning/plan-store"; +import { boringstackPlanSchema } from "../src/loop/boringstack/plan-extension"; import { readHostPorts, hostPortOr } from "../src/scaffold"; import { preflightOrExit, @@ -45,7 +46,7 @@ async function installPlanFile( ): Promise { try { const planContent = await Bun.file(planPath).text(); - const parsed = parsePlan(planContent); + const parsed = parsePlan(planContent, boringstackPlanSchema); if (parsed === null) { return `plan file is malformed or missing required fields: ${planPath}`; @@ -349,7 +350,7 @@ async function main(): Promise { // For a greenfield boringstack clone (has apps/api directory), enforce that an // approved plan is either already in place or supplied via --plan. if (existsSync(join(dir, "apps", "api"))) { - const approvedPlan = await loadApprovedPlan(dir); + const approvedPlan = await loadApprovedPlan(dir, boringstackPlanSchema); if (approvedPlan === null && args.planPath === undefined) { process.stderr.write( diff --git a/packages/core/src/cli/repl.ts b/packages/core/src/cli/repl.ts index 8462d3ab..c7d157ac 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -49,6 +49,7 @@ import { type IStackAdapter, } from "../loop/planning/stack-adapter"; import { boringstackStackAdapter } from "../loop/boringstack/planning"; +import { boringstackPlanSchema } from "../loop/boringstack/plan-extension"; import { loadApprovedPlan } from "../loop/planning/plan-store"; import { loadRecipes } from "../config/recipes"; import { loadAgentSpecs } from "../config/agent-specs"; @@ -251,6 +252,8 @@ async function runGreenfieldPlanning( const result = await runPlanning(dir, { planner: plannerProvider, + // The stack's plan schema (web UI shape, prompt, validator) — BoringStack today. + schema: boringstackPlanSchema, // We only reach here when this stack adapter detected the project, so its // reserved-slice rule always applies (no gap) and every drop is surfaced. constraints: greenfieldConstraints(stack, echo), @@ -952,7 +955,7 @@ export async function repl(args: ICliArgs): Promise { await greenfieldOrSend( args.dir, STACK_ADAPTERS, - async (d) => (await loadApprovedPlan(d)) !== null, + async (d) => (await loadApprovedPlan(d, boringstackPlanSchema)) !== null, (stack) => runGreenfieldPlanning( args.dir, diff --git a/packages/core/src/loop/acceptance/acceptance-spec.ts b/packages/core/src/loop/acceptance/acceptance-spec.ts index 0c606958..c0982fcd 100644 --- a/packages/core/src/loop/acceptance/acceptance-spec.ts +++ b/packages/core/src/loop/acceptance/acceptance-spec.ts @@ -1,4 +1,4 @@ -import type { IProductPlan, IEntitySpec } from "../planning/plan-types"; +import type { IProductPlan, ISlice, IEntitySpec } from "../planning/plan-types"; import type { IAcceptanceSpec, IEntityAcceptance, @@ -8,6 +8,15 @@ import type { ITestIds, } from "./acceptance.types"; +/** The UI fields acceptance generation needs from a slice's `ui` — nav label, shown fields, and + * the screen list. Core is generic over the UI-intent type; the stack adapter injects an + * extractor (`uiFields`) mapping its concrete UI intent to these, so core never names a web shape. */ +export interface IAcceptanceUiFields { + readonly nav: string; + readonly shows: readonly string[]; + readonly screens: readonly string[]; +} + function camel(s: string): string { if (s.length === 0) { return s; @@ -245,9 +254,10 @@ function deriveNegatives( /** * Build a single entity acceptance spec from a slice. */ -function buildEntityAcceptance( - slice: IProductPlan["slices"][number], - index: number +function buildEntityAcceptance( + slice: ISlice, + index: number, + uiFields: (ui: TUi) => IAcceptanceUiFields ): IEntityAcceptance { const fields = initializeFields(slice.entity.fields, index); const parents = parseParents(slice.entity.relationships); @@ -261,13 +271,15 @@ function buildEntityAcceptance( slice.verification.mustNotHappen ); + const ui = uiFields(slice.ui); + return { id: slice.entity.id, key: camel(slice.entity.id), - nav: slice.ui.nav, + nav: ui.nav, fields, - shows: [...slice.ui.shows], - screens: slice.ui.screens, + shows: [...ui.shows], + screens: ui.screens, parents, negatives, acceptanceCheck: slice.verification.acceptanceCheck, @@ -365,9 +377,12 @@ function addMustNotHappenNegatives( } } -export function planToAcceptanceSpec(plan: IProductPlan): IAcceptanceSpec { +export function planToAcceptanceSpec( + plan: IProductPlan, + uiFields: (ui: TUi) => IAcceptanceUiFields +): IAcceptanceSpec { const entities = plan.slices.map((slice, i) => - buildEntityAcceptance(slice, i) + buildEntityAcceptance(slice, i, uiFields) ); return { entities }; diff --git a/packages/core/src/loop/acceptance/acceptance.types.ts b/packages/core/src/loop/acceptance/acceptance.types.ts index 7757447b..8a19b179 100644 --- a/packages/core/src/loop/acceptance/acceptance.types.ts +++ b/packages/core/src/loop/acceptance/acceptance.types.ts @@ -24,7 +24,9 @@ export interface IEntityAcceptance { nav: string; fields: IAcceptField[]; shows: string[]; - screens: readonly ("list" | "detail" | "form" | "dashboard")[]; + // Screen ids the stack's UI intent declares — kept as opaque strings so core acceptance stays + // generic over the UI shape (the web `list|detail|form|dashboard` values live in the adapter). + screens: readonly string[]; parents: IParentRef[]; negatives: INegativeCase[]; acceptanceCheck: string; diff --git a/packages/core/src/loop/boringstack/build.ts b/packages/core/src/loop/boringstack/build.ts index 02404a4c..7bfa6648 100644 --- a/packages/core/src/loop/boringstack/build.ts +++ b/packages/core/src/loop/boringstack/build.ts @@ -24,6 +24,11 @@ import { slicesToFeatures, invalidEntityIds } from "./plan-resources"; import { toCamelCase } from "./case"; import { loadApprovedPlan } from "../planning/plan-store"; import type { ISlice, IProductPlan } from "../planning/plan-types"; +import { + boringstackPlanSchema, + boringstackUiFields, + type IUiIntent, +} from "./plan-extension"; import { planToAcceptanceSpec } from "../acceptance/acceptance-spec"; import { buildTestIdGuide } from "./acceptance/testid-contract"; import type { @@ -37,6 +42,10 @@ import { acceptanceSteer } from "../acceptance/acceptance-steer"; import { readHostPorts, hostPortOr } from "../../scaffold"; import { FLAG_ON, ENV_FLAG } from "../../config/config.constants"; +/** BoringStack builds a concrete web plan — the generic spine specialized to IUiIntent. */ +type BsPlan = IProductPlan; +type BsSlice = ISlice; + /** Apply BoringStack's DETERMINISTIC auto-fixes over both apps before the gate: * `lint:fix` (eslint --fix for the auto-fixable lint rules — padding-line, import order, etc.) * then `format` (prettier, canonical formatting) — in THAT order, prettier LAST. Neither changes @@ -596,7 +605,7 @@ export function boringstackDeps(opts: { generateUi?: (cwd: string, name: string, exec: Exec) => Promise; /** Look up the plan slice for a feature by its id. Supplied by runBoringstackBuild * when building from an approved plan; undefined when planning ad-hoc. */ - sliceFor?: (id: string) => ISlice | undefined; + sliceFor?: (id: string) => BsSlice | undefined; /** The acceptance runner for per-slice E2E verification. When provided and the * flag is enabled, features are gated on per-slice acceptance after the fast gate * passes. */ @@ -663,10 +672,13 @@ export function boringstackDeps(opts: { let entity: IEntityAcceptance | undefined; if (slice !== undefined) { - const spec = planToAcceptanceSpec({ - product: "BoringStack", - slices: [slice], - }); + const spec = planToAcceptanceSpec( + { + product: "BoringStack", + slices: [slice], + }, + boringstackUiFields + ); entity = spec.entities[0]; } @@ -852,7 +864,7 @@ export async function runFinalAcceptance( cwd: string, exec: Exec, acceptanceRunner: IAcceptanceRunner | undefined, - approved: IProductPlan, + approved: BsPlan, e2eAcceptanceDisabled: boolean, onEvent?: Reporter ): Promise { @@ -909,7 +921,7 @@ export async function runFinalAcceptance( uiBase: `http://localhost:${uiPort}`, }; - const spec = planToAcceptanceSpec(approved); + const spec = planToAcceptanceSpec(approved, boringstackUiFields); const chainOutcome = await acceptanceRunner.runChain(spec, ctx); // Infrastructure error: route to infra-abort path, not feature red @@ -959,7 +971,7 @@ export async function runFinalAcceptance( * unlike per-feature implement() wiring (which resume skips → the redirect would silently stay * /dashboard, a false-green). At most one home is enforced by plan validation. */ -export function homeRouteForPlan(plan: IProductPlan): string | null { +export function homeRouteForPlan(plan: BsPlan): string | null { const home = plan.slices.find((s) => s.ui.home === true); return home ? `/${toCamelCase(home.entity.id)}` : null; @@ -975,7 +987,7 @@ export function homeRouteForPlan(plan: IProductPlan): string | null { */ export async function wireHomeRedirectForPlan( cwd: string, - plan: IProductPlan, + plan: BsPlan, apply: (cwd: string, route: string) => Promise = applyHomeRedirect ): Promise { const route = homeRouteForPlan(plan); @@ -1017,7 +1029,7 @@ export async function runBoringstackBuild(opts: { process.env[ENV_FLAG.noE2eAcceptance] === FLAG_ON; // Require an approved plan before building - const approved = await loadApprovedPlan(cwd); + const approved = await loadApprovedPlan(cwd, boringstackPlanSchema); if (approved === null) { return { status: "needs-plan", features: [] }; @@ -1191,7 +1203,7 @@ export async function runBoringstackBuild(opts: { await wireHomeRedirectForPlan(cwd, approved); // Create a lookup function that maps feature ids to their plan slices - const sliceFor = (id: string): ISlice | undefined => + const sliceFor = (id: string): BsSlice | undefined => approved.slices.find((slice) => slice.entity.id === id); // Run the greenfield loop with BoringStack-specific dependencies @@ -1201,7 +1213,7 @@ export async function runBoringstackBuild(opts: { optsGreenfield.onEvent = onEvent; } - const fullSpec = planToAcceptanceSpec(approved); + const fullSpec = planToAcceptanceSpec(approved, boringstackUiFields); const result = await runGreenfield( cwd, diff --git a/packages/core/src/loop/boringstack/plan-extension.ts b/packages/core/src/loop/boringstack/plan-extension.ts new file mode 100644 index 00000000..6b39484a --- /dev/null +++ b/packages/core/src/loop/boringstack/plan-extension.ts @@ -0,0 +1,222 @@ +import { isRecord, isArray } from "../../lib/guards"; +import type { IProductPlan, IPlanSchema } from "../planning/plan-types"; + +/** + * BoringStack's PLAN EXTENSION — the web/SaaS-specific UI-intent shape, its validator, and the + * planner schema (prompt + example), kept OUT of the core plan spine. Core's `ISlice` / + * `IProductPlan` are generic over the UI intent; core's `proposePlan` / `parsePlan` take an + * injected `IPlanSchema`. This file supplies the concrete `IUiIntent` BoringStack uses and + * bundles everything into `boringstackPlanSchema`. A different adapter (a Phaser game) brings its + * own, or a trivial UI-less schema. + */ + +/** The modern-layout archetype VOCABULARY (roadmap) — intentionally broad so the model isn't + * locked into too few options. This tuple drives the `LayoutArchetype` type. It is NOT the set a + * plan may declare: plan validation gates on IMPLEMENTED_LAYOUT_ARCHETYPES (below), so a + * not-yet-built archetype is REJECTED, never silently accepted or fallen-back. Move an entry's + * behaviour into the wiring, add it to IMPLEMENTED_LAYOUT_ARCHETYPES, then plans can use it. */ +export const LAYOUT_ARCHETYPES = [ + "app-sidebar", // left sidebar + header content shell (default SaaS look) + "app-topnav", // horizontal top-nav + content + "settings", // demoted secondary/config area (profile, account, prefs) + "focused", // centered single-column (auth, onboarding) + "public", // unauthenticated marketing/landing +] as const; + +export type LayoutArchetype = (typeof LAYOUT_ARCHETYPES)[number]; + +/** The archetypes the harness actually IMPLEMENTS today — this drives PLAN VALIDATION. The full + * LAYOUT_ARCHETYPES set above is the roadmap/vocabulary; a plan may only DECLARE an implemented + * one. A not-yet-built archetype is rejected rather than silently mis-built — critically `public` + * implies UNAUTHENTICATED, but routing wraps every feature in ProtectedRoute+AppShell, so a + * silently-accepted `public` feature would be authenticated (wrong). Grow this set as archetypes + * ship. */ +export const IMPLEMENTED_LAYOUT_ARCHETYPES = [ + "app-sidebar", + "settings", +] as const; + +export interface IUiIntent { + readonly screens: readonly ("list" | "detail" | "form" | "dashboard")[]; + readonly action: string; // primary user action → observable result + readonly shows: readonly string[]; + readonly nav: string; + /** Which layout archetype this feature's UI uses. Default `app-sidebar`. Drives sidebar + * grouping (primary app nav vs a demoted Settings group) — NOT a separate auth boundary in + * v1 (both stay ProtectedRoute + AppShell). */ + readonly layout?: LayoutArchetype; + /** This feature's route is the post-login landing (the app "home"). At most ONE per plan; if + * none is marked, login falls back to the scaffold default (`/dashboard`). */ + readonly home?: boolean; +} + +/** A fully-typed BoringStack plan: the generic core spine specialized to BoringStack's UI intent. */ +export type BoringstackProductPlan = IProductPlan; + +/** + * Validate a slice's `ui` field is a well-formed `IUiIntent`: web screens, a non-empty action/nav, + * a string `shows` list, an OPTIONAL layout that must be one the harness IMPLEMENTS (a not-yet-built + * archetype like `public` is rejected, not silently mis-built), and an optional boolean `home`. + */ +export function isBoringstackUiIntent(value: unknown): value is IUiIntent { + if (!isRecord(value)) { + return false; + } + + if (!isArray(value.screens)) { + return false; + } + + const validScreens = ["list", "detail", "form", "dashboard"]; + + if ( + !value.screens.every( + (s) => typeof s === "string" && validScreens.includes(s) + ) + ) { + return false; + } + + if (typeof value.action !== "string" || value.action === "") { + return false; + } + + if (!isArray(value.shows)) { + return false; + } + + if (!value.shows.every((x) => typeof x === "string")) { + return false; + } + + if (typeof value.nav !== "string" || value.nav === "") { + return false; + } + + const validLayouts: readonly string[] = IMPLEMENTED_LAYOUT_ARCHETYPES; + + if ( + value.layout !== undefined && + !(typeof value.layout === "string" && validLayouts.includes(value.layout)) + ) { + return false; + } + + if (value.home !== undefined && typeof value.home !== "boolean") { + return false; + } + + return true; +} + +/** + * A complete, valid example plan shown to the model to pin the exact output shape. Typed as + * IProductPlan (via `satisfies`) so the compiler guarantees the example we teach the + * model is itself a legal plan; a regression test asserts it validates. + */ +export const PLANNER_EXAMPLE = { + product: + "A personal task tracker for a single user to capture and complete to-dos.", + slices: [ + { + entity: { + id: "Task", + desc: "A single to-do item owned by a user.", + fields: [ + { name: "title", type: "string" }, + { name: "done", type: "boolean" }, + { name: "dueDate", type: "Date", optional: true }, + ], + relationships: ["belongs to a User"], + rules: ["title must not be empty", "a user only sees their own tasks"], + }, + ui: { + screens: ["list", "detail", "form"], + action: "create, complete, and delete tasks", + shows: ["title", "done", "dueDate"], + nav: "Tasks", + layout: "app-sidebar", + home: true, + }, + verification: { + mustRemainTrue: ["only the owner can see or change a task"], + mustNotHappen: ["a user must not see another user's tasks"], + acceptanceCheck: "bun test", + }, + }, + ], +} satisfies IProductPlan; + +/** + * System prompt for the product architect role: turn a product description + optional mockups into + * a structured product plan (domain model + slices + UI + verification). The schema is pinned with + * EXACT key names and a flat screen enum, plus the worked PLANNER_EXAMPLE, because a loosely- + * described shape lets a model invent its own keys the strict parser then rejects. + */ +export const PLANNER_SYSTEM = `You are a product architect. From the product description and any mockups, propose a domain model as feature slices (one per entity). Respond with ONLY a JSON object — no prose, no markdown fences — matching this schema EXACTLY. Use these exact key names and value shapes; do not add, rename, or nest differently. + +Schema: +{ + "product": "", + "slices": [ + { + "entity": { + "id": "", + "desc": "", + "fields": [ { "name": "", "type": "", "optional": } ], + "relationships": [ "" ], + "rules": [ "" ] + }, + "ui": { + "screens": [ ], + "action": "", + "shows": [ "" ], + "nav": "", + "layout": "", + "home": + }, + "verification": { + "mustRemainTrue": [ "" ], + "mustNotHappen": [ "" ], + "acceptanceCheck": "" + } + } + ] +} + +Rules for the JSON: +- "screens" is a flat array of the literal words list/detail/form/dashboard ONLY — never objects, never other words. +- "relationships" and "rules" are arrays of plain STRINGS, never objects. +- "fields" uses "optional" (boolean), never "required". +- Use "desc" (not "description"), "action" (not "primaryAction"), "nav" (not "navigationLabel"), "acceptanceCheck" (not "acceptanceCheckCommand"). +- "mustNotHappen" must have at least one entry. +- LAYOUT: give the app a real shape. Mark the ONE primary feature the user should land in with "home": true and "layout": "app-sidebar" (the app opens there, not on a generic dashboard). Put configuration/account features (profile, preferences, billing) at "layout": "settings" so they're grouped as a demoted settings area, not the main app. Most product features are "app-sidebar" (the default — you may omit "layout"). Use "home"/"layout" only from the allowed set above; never invent other values. + +Complete example (follow this shape precisely): +${JSON.stringify(PLANNER_EXAMPLE, null, 2)}`; + +/** + * BoringStack's plan schema, injected into core's generic planner + parser: the web UI-schema + * prompt, the worked example, the `ui` validator, and the cross-slice rule that at most ONE slice + * is the app home (the post-login landing). + */ +export const boringstackPlanSchema: IPlanSchema = { + system: PLANNER_SYSTEM, + example: PLANNER_EXAMPLE, + validateUi: isBoringstackUiIntent, + extraCheck: (plan) => + plan.slices.filter((s) => s.ui.home === true).length <= 1, +}; + +/** + * Extract the acceptance UI fields (nav / shows / screens) from a BoringStack UI intent — the + * injected seam for the generic `planToAcceptanceSpec`, so the web UI shape stays out of core's + * acceptance generator. + */ +export const boringstackUiFields = ( + ui: IUiIntent +): { nav: string; shows: readonly string[]; screens: readonly string[] } => ({ + nav: ui.nav, + shows: ui.shows, + screens: ui.screens, +}); diff --git a/packages/core/src/loop/boringstack/refine-prompt.ts b/packages/core/src/loop/boringstack/refine-prompt.ts index 88d8ec0f..10b7a3c6 100644 --- a/packages/core/src/loop/boringstack/refine-prompt.ts +++ b/packages/core/src/loop/boringstack/refine-prompt.ts @@ -1,11 +1,12 @@ import type { IFeature } from "../greenfield/greenfield.types"; import type { ISlice } from "../planning/plan-types"; +import type { IUiIntent } from "./plan-extension"; import { toCamelCase } from "./case"; /** Per-slice layout wiring: where the feature's nav link goes (primary app group vs a demoted * Settings group) and, if it's the app home, the post-login redirect. v1 implements app-sidebar * + settings; the other archetypes build as app-sidebar for now (the enum is broad on purpose). */ -function layoutGuidance(slice: ISlice): string { +function layoutGuidance(slice: ISlice): string { const layout = slice.ui.layout ?? "app-sidebar"; const route = `/${toCamelCase(slice.entity.id)}`; const lines: string[] = []; @@ -37,7 +38,7 @@ function layoutGuidance(slice: ISlice): string { return lines.join("\n\n"); } -function productContextSection(slice: ISlice): string { +function productContextSection(slice: ISlice): string { const fieldsList = slice.entity.fields .map((f) => { const optionalMarker = f.optional === true ? " [optional]" : ""; @@ -112,7 +113,10 @@ ${mustNotList} * - Includes domain-fill instructions (real fields, real logic, no `as` casts) * - States the FREEZE: only this resource's files are editable */ -export function refinePrompt(feature: IFeature, slice?: ISlice): string { +export function refinePrompt( + feature: IFeature, + slice?: ISlice +): string { const camel = toCamelCase(feature.id); // On a retry, lead with the ACTUAL gate/judge errors from the last attempt so the diff --git a/packages/core/src/loop/planning/plan-store.ts b/packages/core/src/loop/planning/plan-store.ts index c46f44d6..a74887cd 100644 --- a/packages/core/src/loop/planning/plan-store.ts +++ b/packages/core/src/loop/planning/plan-store.ts @@ -5,16 +5,15 @@ import type { IProductPlan, ISlice, IEntitySpec, - IUiIntent, + IPlanSchema, IVerificationContract, } from "./plan-types"; -import { IMPLEMENTED_LAYOUT_ARCHETYPES } from "./plan-types"; /** * Serialize a plan to YAML frontmatter + fenced JSON format. */ -export function serializePlan( - plan: IProductPlan, +export function serializePlan( + plan: IProductPlan, status: "draft" | "approved" ): string { const frontmatter = `--- @@ -106,65 +105,6 @@ function isEntitySpec(value: unknown): value is IEntitySpec { return true; } -/** - * Guard: validate a UI intent shape. - */ -function isUiIntent(value: unknown): value is IUiIntent { - if (!isRecord(value)) { - return false; - } - - if (!isArray(value.screens)) { - return false; - } - - const validScreens = ["list", "detail", "form", "dashboard"]; - - if ( - !value.screens.every( - (s) => typeof s === "string" && validScreens.includes(s) - ) - ) { - return false; - } - - if (typeof value.action !== "string" || value.action === "") { - return false; - } - - if (!isArray(value.shows)) { - return false; - } - - if (!value.shows.every((x) => typeof x === "string")) { - return false; - } - - if (typeof value.nav !== "string" || value.nav === "") { - return false; - } - - // Optional layout archetype: if present it must be one the harness IMPLEMENTS. Validation gates - // on IMPLEMENTED_LAYOUT_ARCHETYPES (a subset of the LayoutArchetype vocabulary) — a not-yet-built - // archetype (e.g. `public`, which implies unauthenticated but would be wrapped in ProtectedRoute) - // is REJECTED rather than silently mis-built. - const validLayouts: readonly string[] = IMPLEMENTED_LAYOUT_ARCHETYPES; - - if ( - value.layout !== undefined && - !(typeof value.layout === "string" && validLayouts.includes(value.layout)) - ) { - return false; - } - - // Optional home flag: post-login landing marker. - if (value.home !== undefined && typeof value.home !== "boolean") { - return false; - } - - return true; -} - /** * Guard: validate a verification contract shape. */ @@ -206,9 +146,13 @@ function isVerificationContract( } /** - * Guard: validate a slice shape. + * Guard: validate a slice shape. Generic over the UI-intent type — the caller injects `validateUi` + * (the stack adapter's `IPlanSchema.validateUi`), so core never names a concrete web UI shape. */ -function isSlice(value: unknown): value is ISlice { +function isSlice( + value: unknown, + validateUi: (v: unknown) => v is TUi +): value is ISlice { if (!isRecord(value)) { return false; } @@ -217,7 +161,7 @@ function isSlice(value: unknown): value is ISlice { return false; } - if (!isUiIntent(value.ui)) { + if (!validateUi(value.ui)) { return false; } @@ -229,9 +173,16 @@ function isSlice(value: unknown): value is ISlice { } /** - * Guard: validate a product plan shape. + * Guard: validate a product plan shape. Core validates the SPINE (product + each slice's entity + + * verification); the slice `ui` is validated by the injected `validateUi`, and any cross-slice rule + * (e.g. BoringStack's "≤1 home") by the optional `extraCheck` — both supplied by the stack's + * `IPlanSchema`, so no web-specific rule lives in core. */ -export function isProductPlan(value: unknown): value is IProductPlan { +export function isProductPlan( + value: unknown, + validateUi: (v: unknown) => v is TUi, + extraCheck?: (plan: IProductPlan) => boolean +): value is IProductPlan { if (!isRecord(value)) { return false; } @@ -244,17 +195,20 @@ export function isProductPlan(value: unknown): value is IProductPlan { return false; } - if (!value.slices.every(isSlice)) { + if (!value.slices.every((s) => isSlice(s, validateUi))) { return false; } - // At most ONE slice may be the app home (the post-login landing). isRecord guards keep this - // cast-free even though `value.slices` is still `unknown[]` at this point. - const homeCount = value.slices.filter( - (s) => isRecord(s) && isRecord(s.ui) && s.ui.home === true - ).length; + // `value` is now shape-verified as IProductPlan; the narrowing predicate lets us hand it to + // the stack's cross-slice rule without a cast. + const plan: IProductPlan = { + product: value.product, + slices: value.slices.filter((s): s is ISlice => + isSlice(s, validateUi) + ), + }; - if (homeCount > 1) { + if (extraCheck !== undefined && !extraCheck(plan)) { return false; } @@ -316,9 +270,10 @@ function extractJsonBlock(text: string, startIndex: number): string | null { * Parse a serialized plan. Returns null if the artifact is malformed. * Reject-by-default: any shape mismatch returns null, never a partial plan. */ -export function parsePlan( - text: string -): { plan: IProductPlan; status: "draft" | "approved" } | null { +export function parsePlan( + text: string, + schema: IPlanSchema +): { plan: IProductPlan; status: "draft" | "approved" } | null { const fmResult = extractFrontmatter(text); if (!fmResult) { @@ -343,7 +298,7 @@ export function parsePlan( return null; } - if (!isProductPlan(plan)) { + if (!isProductPlan(plan, schema.validateUi, schema.extraCheck)) { return null; } @@ -356,9 +311,9 @@ export function parsePlan( /** * Write a plan to ${cwd}/.specs/next.md, creating .specs/ if needed. */ -export async function writePlan( +export async function writePlan( cwd: string, - plan: IProductPlan, + plan: IProductPlan, status: "draft" | "approved" ): Promise { const specsDir = join(cwd, ".specs"); @@ -372,16 +327,18 @@ export async function writePlan( /** * Read a plan from ${cwd}/.specs/next.md. Returns null if the file doesn't exist or is malformed. + * `schema` (the stack's `IPlanSchema`) validates the slice `ui` at the parse boundary. */ -export async function readPlan( - cwd: string -): Promise<{ plan: IProductPlan; status: "draft" | "approved" } | null> { +export async function readPlan( + cwd: string, + schema: IPlanSchema +): Promise<{ plan: IProductPlan; status: "draft" | "approved" } | null> { const filePath = join(cwd, ".specs", "next.md"); try { const content = await Bun.file(filePath).text(); - return parsePlan(content); + return parsePlan(content, schema); } catch { return null; } @@ -391,10 +348,11 @@ export async function readPlan( * Load an approved plan from ${cwd}/.specs/next.md. * Returns the plan only when status === "approved", else null. */ -export async function loadApprovedPlan( - cwd: string -): Promise { - const result = await readPlan(cwd); +export async function loadApprovedPlan( + cwd: string, + schema: IPlanSchema +): Promise | null> { + const result = await readPlan(cwd, schema); if (result === null) { return null; diff --git a/packages/core/src/loop/planning/plan-types.ts b/packages/core/src/loop/planning/plan-types.ts index 3664d0d0..4351612a 100644 --- a/packages/core/src/loop/planning/plan-types.ts +++ b/packages/core/src/loop/planning/plan-types.ts @@ -10,61 +10,47 @@ export interface IEntitySpec { readonly rules: readonly string[]; } -/** The modern-layout archetype VOCABULARY (roadmap) — intentionally broad so the model isn't - * locked into too few options. This tuple drives the `LayoutArchetype` type. It is NOT the set a - * plan may declare: plan validation gates on IMPLEMENTED_LAYOUT_ARCHETYPES (below), so a - * not-yet-built archetype is REJECTED, never silently accepted or fallen-back. Move an entry's - * behaviour into the wiring, add it to IMPLEMENTED_LAYOUT_ARCHETYPES, then plans can use it. */ -export const LAYOUT_ARCHETYPES = [ - "app-sidebar", // left sidebar + header content shell (default SaaS look) - "app-topnav", // horizontal top-nav + content - "settings", // demoted secondary/config area (profile, account, prefs) - "focused", // centered single-column (auth, onboarding) - "public", // unauthenticated marketing/landing -] as const; - -export type LayoutArchetype = (typeof LAYOUT_ARCHETYPES)[number]; - -/** The archetypes the harness actually IMPLEMENTS today — this drives PLAN VALIDATION. The full - * LAYOUT_ARCHETYPES set above is the roadmap/vocabulary; a plan may only DECLARE an implemented - * one. A not-yet-built archetype is rejected rather than silently mis-built — critically `public` - * implies UNAUTHENTICATED, but routing wraps every feature in ProtectedRoute+AppShell, so a - * silently-accepted `public` feature would be authenticated (wrong). Grow this set as archetypes - * ship. */ -export const IMPLEMENTED_LAYOUT_ARCHETYPES = [ - "app-sidebar", - "settings", -] as const; - -export interface IUiIntent { - readonly screens: readonly ("list" | "detail" | "form" | "dashboard")[]; - readonly action: string; // primary user action → observable result - readonly shows: readonly string[]; - readonly nav: string; - /** Which layout archetype this feature's UI uses. Default `app-sidebar`. Drives sidebar - * grouping (primary app nav vs a demoted Settings group) — NOT a separate auth boundary in - * v1 (both stay ProtectedRoute + AppShell). */ - readonly layout?: LayoutArchetype; - /** This feature's route is the post-login landing (the app "home"). At most ONE per plan; if - * none is marked, login falls back to the scaffold default (`/dashboard`). */ - readonly home?: boolean; -} - export interface IVerificationContract { readonly mustRemainTrue: readonly string[]; readonly mustNotHappen: readonly string[]; // ≥1 readonly acceptanceCheck: string; // runnable command, outcome-oriented } -export interface ISlice { +/** + * A plan slice: a domain entity + its verification contract + a stack-specific UI intent. The core + * spine is GENERIC over the UI-intent type `TUi` — core never names a concrete UI shape (screens, + * nav, layout are WEB concepts). A stack adapter supplies the concrete `TUi` (BoringStack's + * `IUiIntent`) and the schema that validates it (see `IPlanSchema`). `TUi = unknown` by default, + * so a UI-agnostic caller sees `ui` as opaque rather than a hardcoded web shape. + */ +export interface ISlice { readonly entity: IEntitySpec; - readonly ui: IUiIntent; + readonly ui: TUi; readonly verification: IVerificationContract; } -export interface IProductPlan { +export interface IProductPlan { readonly product: string; // one-paragraph purpose - readonly slices: readonly ISlice[]; + readonly slices: readonly ISlice[]; +} + +/** + * The STACK-SPECIFIC plan schema the generic planner + parser depend on, injected by the adapter + * (BoringStack today). Core's `proposePlan` teaches `system` to the model, uses `example` to pin + * the exact output shape, validates each slice's `ui` with `validateUi` at the parse boundary, and + * applies the optional cross-slice `extraCheck`. This is what keeps the WEB plan shape (screens, + * nav, layout, home) OUT of core — a Phaser adapter would supply its own schema, or a UI-less one + * a trivial pass-through. + */ +export interface IPlanSchema { + /** System-prompt text teaching the model this stack's exact plan/UI shape. */ + readonly system: string; + /** A complete, valid example plan (serialized into the prompt) pinning the output shape. */ + readonly example: IProductPlan; + /** Validates a slice's `ui` field at the parse boundary (reject-by-default). */ + readonly validateUi: (value: unknown) => value is TUi; + /** Optional cross-slice rule (e.g. "≤1 home"); returns false to reject the plan. */ + readonly extraCheck?: (plan: IProductPlan) => boolean; } /** OPT-IN, stack-specific planning constraints for proposePlan. Absent → the diff --git a/packages/core/src/loop/planning/propose-plan.ts b/packages/core/src/loop/planning/propose-plan.ts index 8ddfb0d3..0ae03b9a 100644 --- a/packages/core/src/loop/planning/propose-plan.ts +++ b/packages/core/src/loop/planning/propose-plan.ts @@ -1,106 +1,23 @@ import type { IProvider } from "../../inference"; import { extractJson } from "../../lib/json"; import { isProductPlan } from "./plan-store"; -import type { IProductPlan, IPlanConstraints } from "./plan-types"; +import type { IProductPlan, IPlanConstraints, IPlanSchema } from "./plan-types"; /** - * A complete, valid example plan shown to the model to pin the exact output - * shape. Typed as IProductPlan (via `satisfies`) so the compiler guarantees the - * example we teach the model is itself a legal plan; a regression test asserts - * `isProductPlan(PLANNER_EXAMPLE)`. Serialized into PLANNER_SYSTEM below. - */ -export const PLANNER_EXAMPLE = { - product: - "A personal task tracker for a single user to capture and complete to-dos.", - slices: [ - { - entity: { - id: "Task", - desc: "A single to-do item owned by a user.", - fields: [ - { name: "title", type: "string" }, - { name: "done", type: "boolean" }, - { name: "dueDate", type: "Date", optional: true }, - ], - relationships: ["belongs to a User"], - rules: ["title must not be empty", "a user only sees their own tasks"], - }, - ui: { - screens: ["list", "detail", "form"], - action: "create, complete, and delete tasks", - shows: ["title", "done", "dueDate"], - nav: "Tasks", - layout: "app-sidebar", - home: true, - }, - verification: { - mustRemainTrue: ["only the owner can see or change a task"], - mustNotHappen: ["a user must not see another user's tasks"], - acceptanceCheck: "bun test", - }, - }, - ], -} satisfies IProductPlan; - -/** - * System prompt for the product architect role: turn a product description - * + optional mockups into a structured product plan (domain model + slices + UI - * + verification). The schema is pinned with EXACT key names and a flat screen - * enum, plus the worked PLANNER_EXAMPLE, because a loosely-described shape lets - * a model invent its own keys (primaryAction/navigationLabel/screen objects) - * that the strict parser then rejects. - */ -export const PLANNER_SYSTEM = `You are a product architect. From the product description and any mockups, propose a domain model as feature slices (one per entity). Respond with ONLY a JSON object — no prose, no markdown fences — matching this schema EXACTLY. Use these exact key names and value shapes; do not add, rename, or nest differently. - -Schema: -{ - "product": "", - "slices": [ - { - "entity": { - "id": "", - "desc": "", - "fields": [ { "name": "", "type": "", "optional": } ], - "relationships": [ "" ], - "rules": [ "" ] - }, - "ui": { - "screens": [ ], - "action": "", - "shows": [ "" ], - "nav": "", - "layout": "", - "home": - }, - "verification": { - "mustRemainTrue": [ "" ], - "mustNotHappen": [ "" ], - "acceptanceCheck": "" - } - } - ] -} - -Rules for the JSON: -- "screens" is a flat array of the literal words list/detail/form/dashboard ONLY — never objects, never other words. -- "relationships" and "rules" are arrays of plain STRINGS, never objects. -- "fields" uses "optional" (boolean), never "required". -- Use "desc" (not "description"), "action" (not "primaryAction"), "nav" (not "navigationLabel"), "acceptanceCheck" (not "acceptanceCheckCommand"). -- "mustNotHappen" must have at least one entry. -- LAYOUT: give the app a real shape. Mark the ONE primary feature the user should land in with "home": true and "layout": "app-sidebar" (the app opens there, not on a generic dashboard). Put configuration/account features (profile, preferences, billing) at "layout": "settings" so they're grouped as a demoted settings area, not the main app. Most product features are "app-sidebar" (the default — you may omit "layout"). Use "home"/"layout" only from the allowed set above; never invent other values. - -Complete example (follow this shape precisely): -${JSON.stringify(PLANNER_EXAMPLE, null, 2)}`; - -/** - * Parse the planner's raw JSON reply into an IProductPlan, or null on failure. + * Parse the planner's raw JSON reply into an IProductPlan, or null on failure. Generic over the + * UI-intent type: the slice `ui` and any cross-slice rule are validated by the injected schema + * (`validateUi` / `extraCheck`), so no web-specific plan shape lives in this generic planner. * Pure — split out so it can be unit-tested without a provider. */ -export function parsePlanJson(raw: string): IProductPlan | null { +export function parsePlanJson( + raw: string, + validateUi: (v: unknown) => v is TUi, + extraCheck?: (plan: IProductPlan) => boolean +): IProductPlan | null { try { const json: unknown = JSON.parse(extractJson(raw)); - return isProductPlan(json) ? json : null; + return isProductPlan(json, validateUi, extraCheck) ? json : null; } catch { return null; } @@ -111,10 +28,10 @@ export function parsePlanJson(raw: string): IProductPlan | null { * return a plan with ZERO slices — an all-reserved response is mis-scoped, and the * caller turns an empty result into null (a FINITE planning failure), strictly * better than re-emitting the reserved slices into an infinite build loop. */ -export function stripReservedSlices( - plan: IProductPlan, +export function stripReservedSlices( + plan: IProductPlan, reserved: ReadonlySet -): IProductPlan { +): IProductPlan { return { ...plan, slices: plan.slices.filter( @@ -135,21 +52,27 @@ export function stripReservedSlices( * (all-reserved, mis-scoped) the result is null — a finite planning failure, never * a plan that re-emits the reserved-slice trap. */ -export async function proposePlan( +export async function proposePlan( deps: { planner: IProvider }, input: { description: string; mockups?: readonly string[] }, + schema: IPlanSchema, constraints: IPlanConstraints = {} -): Promise { +): Promise | null> { const system = constraints.guidance === undefined - ? PLANNER_SYSTEM - : `${PLANNER_SYSTEM}\n\n${constraints.guidance}`; + ? schema.system + : `${schema.system}\n\n${constraints.guidance}`; const userMessage = input.mockups !== undefined && input.mockups.length > 0 ? `Product description: ${input.description}\n\nMockup refs: ${input.mockups.join(", ")}` : `Product description: ${input.description}`; - const usable = (parsed: IProductPlan | null): IProductPlan | null => { + const parse = (raw: string): IProductPlan | null => + parsePlanJson(raw, schema.validateUi, schema.extraCheck); + + const usable = ( + parsed: IProductPlan | null + ): IProductPlan | null => { if (parsed === null) { return null; } @@ -187,7 +110,7 @@ export async function proposePlan( // A first attempt that fails to parse OR strips to zero usable slices both fall // through to the higher-temperature retry — a fresh attempt may yield real domain // slices. Only when the retry also yields nothing usable is the result null. - const first = usable(parsePlanJson(res1.content)); + const first = usable(parse(res1.content)); if (first !== null) { return first; @@ -202,5 +125,5 @@ export async function proposePlan( { temperature: 0.7 } ); - return usable(parsePlanJson(res2.content)); + return usable(parse(res2.content)); } diff --git a/packages/core/src/loop/planning/run-planning.ts b/packages/core/src/loop/planning/run-planning.ts index 68ea38b1..f6206576 100644 --- a/packages/core/src/loop/planning/run-planning.ts +++ b/packages/core/src/loop/planning/run-planning.ts @@ -1,17 +1,20 @@ import { proposePlan } from "./propose-plan"; import { writePlan } from "./plan-store"; -import type { IProductPlan, IPlanConstraints } from "./plan-types"; +import type { IProductPlan, IPlanConstraints, IPlanSchema } from "./plan-types"; import type { IProvider } from "../../inference"; -export interface IPlanningDeps { +export interface IPlanningDeps { planner: IProvider; + /** The stack's plan schema (prompt + example + UI validator + cross-slice rule), injected by the + * caller from the resolved stack adapter — this is what keeps the web plan shape out of core. */ + schema: IPlanSchema; /** OPT-IN stack-specific planning constraints (guidance + reserved entities). * Omitted → the planner is stack-agnostic. The BoringStack path supplies the * BoringStack constants; a plain build passes nothing. */ constraints?: IPlanConstraints; describe: () => Promise<{ description: string; mockups?: readonly string[] }>; review: ( - plan: IProductPlan + plan: IProductPlan ) => Promise< | { action: "approve" } | { action: "revise"; note: string } @@ -20,9 +23,9 @@ export interface IPlanningDeps { out: (s: string) => void; } -export async function runPlanning( +export async function runPlanning( cwd: string, - deps: IPlanningDeps + deps: IPlanningDeps ): Promise<"approved" | "cancelled"> { const maxRevisions = 5; let revisionCount = 0; @@ -32,6 +35,7 @@ export async function runPlanning( const plan = await proposePlan( { planner: deps.planner }, currentInput, + deps.schema, deps.constraints ?? {} ); diff --git a/packages/core/tests/acceptance-spec.test.ts b/packages/core/tests/acceptance-spec.test.ts index 530fcdfb..19c12e8b 100644 --- a/packages/core/tests/acceptance-spec.test.ts +++ b/packages/core/tests/acceptance-spec.test.ts @@ -6,8 +6,23 @@ import { fieldIsMentioned, } from "../src/loop/acceptance/acceptance-spec"; import type { IAcceptField } from "../src/loop/acceptance/acceptance.types"; +import { type IUiIntent } from "../src/loop/boringstack/plan-extension"; + +// The generic acceptance generator takes an injected UI-field extractor; these tests use the +// BoringStack web UI intent, so wrap with that extractor. +const uiFields = ( + ui: IUiIntent +): { nav: string; shows: readonly string[]; screens: readonly string[] } => ({ + nav: ui.nav, + shows: ui.shows, + screens: ui.screens, +}); +const toSpec = ( + plan: IProductPlan +): ReturnType => + planToAcceptanceSpec(plan, uiFields); -const plan: IProductPlan = { +const plan: IProductPlan = { product: "CRM", slices: [ { @@ -69,7 +84,7 @@ test("testIdsFor derives the stable contract from the entity key", () => { }); test("planToAcceptanceSpec: entity key is camelCase, nav/shows/acceptanceCheck carried", () => { - const spec = planToAcceptanceSpec(plan); + const spec = toSpec(plan); const company = spec.entities[0]; if (!company) { @@ -83,7 +98,7 @@ test("planToAcceptanceSpec: entity key is camelCase, nav/shows/acceptanceCheck c }); test("planToAcceptanceSpec: relationships parse to parent + fkField", () => { - const contact = planToAcceptanceSpec(plan).entities[1]; + const contact = toSpec(plan).entities[1]; if (!contact) { throw new Error("contact entity not found"); @@ -95,7 +110,7 @@ test("planToAcceptanceSpec: relationships parse to parent + fkField", () => { }); test("planToAcceptanceSpec: 'belongs to a User' (implicit auth owner) yields NO parent — not a phantom `aId`/`userId` select/seed", () => { - const spec = planToAcceptanceSpec({ + const spec = toSpec({ product: "p", slices: [ { @@ -125,7 +140,7 @@ test("planToAcceptanceSpec: 'belongs to a User' (implicit auth owner) yields NO }); test("planToAcceptanceSpec: 'belongs to a Company' (leading article) parses to Company, not the article", () => { - const spec = planToAcceptanceSpec({ + const spec = toSpec({ product: "p", slices: [ { @@ -152,7 +167,7 @@ test("planToAcceptanceSpec: 'belongs to a Company' (leading article) parses to C }); test("planToAcceptanceSpec: negatives derive missing-required + bad-email", () => { - const spec = planToAcceptanceSpec(plan); + const spec = toSpec(plan); const company = spec.entities[0]; const contact = spec.entities[1]; @@ -175,12 +190,12 @@ test("planToAcceptanceSpec: negatives derive missing-required + bad-email", () = }); test("planToAcceptanceSpec: sample values are deterministic across calls", () => { - expect(planToAcceptanceSpec(plan)).toEqual(planToAcceptanceSpec(plan)); + expect(toSpec(plan)).toEqual(toSpec(plan)); }); test("planToAcceptanceSpec: rule-based negatives only for REQUIRED fields", () => { // Create a plan with a required field and an optional field, both with rules - const planWithOptional: IProductPlan = { + const planWithOptional: IProductPlan = { product: "CRM", slices: [ { @@ -212,7 +227,7 @@ test("planToAcceptanceSpec: rule-based negatives only for REQUIRED fields", () = ], }; - const spec = planToAcceptanceSpec(planWithOptional); + const spec = toSpec(planWithOptional); const product = spec.entities[0]; if (!product) { @@ -238,7 +253,7 @@ test("planToAcceptanceSpec: rule-based negatives only for REQUIRED fields", () = test("FIX 7: mustNotHappen uses field-mention scan, matches real plan prose", () => { // Real plan prose that previous narrow regex didn't match - const planWithRealProse: IProductPlan = { + const planWithRealProse: IProductPlan = { product: "CRM", slices: [ { @@ -271,7 +286,7 @@ test("FIX 7: mustNotHappen uses field-mention scan, matches real plan prose", () ], }; - const spec = planToAcceptanceSpec(planWithRealProse); + const spec = toSpec(planWithRealProse); const company = spec.entities[0]; if (!company) { @@ -284,7 +299,7 @@ test("FIX 7: mustNotHappen uses field-mention scan, matches real plan prose", () expect(nameNegatives.some((n) => n.value === "")).toBe(true); // Phrase mentioning no known field should not create negatives - const planWithUnknownField: IProductPlan = { + const planWithUnknownField: IProductPlan = { product: "CRM", slices: [ { @@ -312,7 +327,7 @@ test("FIX 7: mustNotHappen uses field-mention scan, matches real plan prose", () ], }; - const specWithUnknown = planToAcceptanceSpec(planWithUnknownField); + const specWithUnknown = toSpec(planWithUnknownField); const companyWithUnknown = specWithUnknown.entities[0]; if (!companyWithUnknown) { @@ -328,7 +343,7 @@ test("FIX 7: mustNotHappen uses field-mention scan, matches real plan prose", () }); test("FIX 7: mustNotHappen does not create duplicate negatives", () => { - const planWithDuplicates: IProductPlan = { + const planWithDuplicates: IProductPlan = { product: "CRM", slices: [ { @@ -356,7 +371,7 @@ test("FIX 7: mustNotHappen does not create duplicate negatives", () => { ], }; - const spec = planToAcceptanceSpec(planWithDuplicates); + const spec = toSpec(planWithDuplicates); const company = spec.entities[0]; if (!company) { @@ -376,7 +391,7 @@ test("FIX 7: mustNotHappen field-mention scan matches real plan prose", () => { // FIX 7: mustNotHappen now uses field-MENTION scan to match real prose // Test with a field that has NO required constraint (optional field) // so mustNotHappen is the only source of the negative - const planWithMustNotHappenOptional: IProductPlan = { + const planWithMustNotHappenOptional: IProductPlan = { product: "CRM", slices: [ { @@ -406,7 +421,7 @@ test("FIX 7: mustNotHappen field-mention scan matches real plan prose", () => { ], }; - const spec = planToAcceptanceSpec(planWithMustNotHappenOptional); + const spec = toSpec(planWithMustNotHappenOptional); const company = spec.entities[0]; if (!company) { @@ -425,7 +440,7 @@ test("FIX 7: mustNotHappen field-mention scan matches real plan prose", () => { test("FIX 7: mustNotHappen with no matching field is skipped (no pseudo-negatives)", () => { // Phrase mentioning no known field should be skipped - const planNoMatch: IProductPlan = { + const planNoMatch: IProductPlan = { product: "CRM", slices: [ { @@ -453,7 +468,7 @@ test("FIX 7: mustNotHappen with no matching field is skipped (no pseudo-negative ], }; - const spec = planToAcceptanceSpec(planNoMatch); + const spec = toSpec(planNoMatch); const company = spec.entities[0]; if (!company) { @@ -470,7 +485,7 @@ test("FIX 7: mustNotHappen with no matching field is skipped (no pseudo-negative test("FIX 7: mustNotHappen does not duplicate negatives when field already has one", () => { // If a field already has a negative from rules, mustNotHappen should not add a duplicate - const planDuplicate: IProductPlan = { + const planDuplicate: IProductPlan = { product: "CRM", slices: [ { @@ -496,7 +511,7 @@ test("FIX 7: mustNotHappen does not duplicate negatives when field already has o ], }; - const spec = planToAcceptanceSpec(planDuplicate); + const spec = toSpec(planDuplicate); const company = spec.entities[0]; if (!company) { @@ -569,7 +584,7 @@ test("planToAcceptanceSpec: date-typed fields get a REAL calendar date sample (n expect(isRealCalendarDate("2024-02-31")).toBe(false); expect(isRealCalendarDate("2024-06-15")).toBe(true); - const datePlan: IProductPlan = { + const datePlan: IProductPlan = { product: "Tracker", slices: [ { @@ -608,7 +623,7 @@ test("planToAcceptanceSpec: date-typed fields get a REAL calendar date sample (n ], }; - const task = planToAcceptanceSpec(datePlan).entities[0]; + const task = toSpec(datePlan).entities[0]; // Cover every date-ish type the production branch handles, incl. a date field whose name // matches the email heuristic (emailVerifiedAt) — TYPE must take precedence over name. diff --git a/packages/core/tests/boringstack-build.test.ts b/packages/core/tests/boringstack-build.test.ts index ddc6bf35..98c35cd4 100644 --- a/packages/core/tests/boringstack-build.test.ts +++ b/packages/core/tests/boringstack-build.test.ts @@ -32,6 +32,7 @@ import type { IGate } from "../src/gate/gate-runner"; import { writePlan } from "../src/loop/planning/plan-store"; import { saveState } from "../src/loop/greenfield/state"; import type { IProductPlan, ISlice } from "../src/loop/planning/plan-types"; +import { type IUiIntent } from "../src/loop/boringstack/plan-extension"; function feature(id: string) { return { id, desc: `Build ${id} resource`, passes: false, attempts: 0 }; @@ -92,7 +93,7 @@ function createEvaluator(): IProvider { } /** A minimal single-slice approved plan, shared by the baseline-persistence tests. */ -function invoicePlan(): IProductPlan { +function invoicePlan(): IProductPlan { return { product: "A simple app", slices: [ @@ -121,7 +122,7 @@ function invoicePlan(): IProductPlan { } describe("homeRouteForPlan", () => { - const mkSlice = (id: string, home: boolean): ISlice => ({ + const mkSlice = (id: string, home: boolean): ISlice => ({ entity: { id, desc: "d", @@ -142,7 +143,10 @@ describe("homeRouteForPlan", () => { acceptanceCheck: "bun test", }, }); - const plan = (slices: ISlice[]): IProductPlan => ({ product: "p", slices }); + const plan = (slices: ISlice[]): IProductPlan => ({ + product: "p", + slices, + }); test("returns the /camel route of the slice marked home", () => { expect( @@ -594,7 +598,7 @@ describe("runBoringstackBuild", () => { const dir = await mkdtemp(join(tmpdir(), "bs-")); try { - const plan: IProductPlan = { + const plan: IProductPlan = { product: "A simple app", slices: [ { @@ -656,7 +660,7 @@ describe("runBoringstackBuild", () => { try { // Write an approved plan - const plan: IProductPlan = { + const plan: IProductPlan = { product: "A simple app", slices: [ { @@ -732,7 +736,7 @@ describe("runBoringstackBuild", () => { "utf-8" ); - const plan: IProductPlan = { + const plan: IProductPlan = { product: "A todo app", slices: [ { @@ -800,7 +804,7 @@ describe("runBoringstackBuild", () => { "utf-8" ); - const plan: IProductPlan = { + const plan: IProductPlan = { product: "A todo app", slices: [ { @@ -870,7 +874,7 @@ describe("runBoringstackBuild", () => { await mkdir(join(constsPath, ".."), { recursive: true }); await writeFile(constsPath, original, "utf-8"); - const plan: IProductPlan = { + const plan: IProductPlan = { product: "A todo app", slices: [ { @@ -948,7 +952,7 @@ describe("runBoringstackBuild", () => { "utf-8" ); - const plan: IProductPlan = { + const plan: IProductPlan = { product: "A todo app", slices: [ { @@ -1016,7 +1020,7 @@ describe("runBoringstackBuild", () => { const dir = await mkdtemp(join(tmpdir(), "bs-")); try { - const plan: IProductPlan = { + const plan: IProductPlan = { product: "A simple app", slices: [ { @@ -1084,7 +1088,7 @@ describe("runBoringstackBuild", () => { try { process.env.TSFORGE_NO_E2E_ACCEPTANCE = "1"; - const plan: IProductPlan = { + const plan: IProductPlan = { product: "A simple app", slices: [ { @@ -2016,7 +2020,7 @@ describe("baseline persistence — resume-safe differential grading", () => { const dir = await mkdtemp(join(tmpdir(), "tsforge-baseline-e2e-")); try { - const plan: IProductPlan = { + const plan: IProductPlan = { product: "A simple app", slices: [ { @@ -2092,7 +2096,7 @@ describe("baseline persistence — resume-safe differential grading", () => { const dir = await mkdtemp(join(tmpdir(), "tsforge-baseline-strict-")); try { - const plan: IProductPlan = { + const plan: IProductPlan = { product: "A simple app", slices: [ { diff --git a/packages/core/tests/boringstack-e2e-generator.test.ts b/packages/core/tests/boringstack-e2e-generator.test.ts index 246c75e4..8dd19294 100644 --- a/packages/core/tests/boringstack-e2e-generator.test.ts +++ b/packages/core/tests/boringstack-e2e-generator.test.ts @@ -12,6 +12,10 @@ import type { IAcceptField, } from "../src/loop/acceptance/acceptance.types"; import type { IProductPlan } from "../src/loop/planning/plan-types"; +import { + boringstackUiFields, + type IUiIntent, +} from "../src/loop/boringstack/plan-extension"; const company: IEntityAcceptance = { id: "Company", @@ -802,7 +806,7 @@ describe("E2E spec generator - Relationships", () => { describe("FIX 11: planToAcceptanceSpec FK dedup", () => { test("a plan already declaring the FK field yields exactly one occurrence", () => { - const planWithDeclaredFk: IProductPlan = { + const planWithDeclaredFk: IProductPlan = { product: "CRM", slices: [ { @@ -852,7 +856,7 @@ describe("FIX 11: planToAcceptanceSpec FK dedup", () => { ], }; - const spec = planToAcceptanceSpec(planWithDeclaredFk); + const spec = planToAcceptanceSpec(planWithDeclaredFk, boringstackUiFields); const contact = spec.entities[1]; if (!contact) { diff --git a/packages/core/tests/boringstack-final-acceptance.test.ts b/packages/core/tests/boringstack-final-acceptance.test.ts index d927e189..502a5549 100644 --- a/packages/core/tests/boringstack-final-acceptance.test.ts +++ b/packages/core/tests/boringstack-final-acceptance.test.ts @@ -9,6 +9,7 @@ import { runFinalAcceptance } from "../src/loop/boringstack/build"; import type { Exec } from "../src/loop/boringstack/exec"; import type { IGreenfieldResult } from "../src/loop/greenfield/greenfield.types"; import type { IProductPlan } from "../src/loop/planning/plan-types"; +import { type IUiIntent } from "../src/loop/boringstack/plan-extension"; /** * Tests for final-acceptance verification (called after all features pass fast gate). @@ -199,7 +200,7 @@ test("final acceptance: runner missing when acceptance enabled → fail-closed ( return { code: 0, stdout: "", stderr: "" }; }; - const minimalPlan: IProductPlan = { + const minimalPlan: IProductPlan = { product: "Test", slices: [ { @@ -255,7 +256,7 @@ test("final acceptance: runner missing when acceptance disabled → returns done return { code: 0, stdout: "", stderr: "" }; }; - const minimalPlan: IProductPlan = { + const minimalPlan: IProductPlan = { product: "Test", slices: [ { @@ -425,7 +426,7 @@ test("runFinalAcceptance: non-done status → returns input unchanged", async () stderr: "", }); - const minimalPlan: IProductPlan = { + const minimalPlan: IProductPlan = { product: "Test", slices: [], }; @@ -468,7 +469,7 @@ test("runFinalAcceptance: gate passes + runChain returns infraError → needs-in }, }; - const minimalPlan: IProductPlan = { + const minimalPlan: IProductPlan = { product: "Test", slices: [], }; @@ -511,7 +512,7 @@ test("runFinalAcceptance: gate passes + runChain returns ok:false → stuck", as }, }; - const minimalPlan: IProductPlan = { + const minimalPlan: IProductPlan = { product: "Test", slices: [], }; @@ -554,7 +555,7 @@ test("runFinalAcceptance: gate passes + runChain returns ok:true → done", asyn }, }; - const minimalPlan: IProductPlan = { + const minimalPlan: IProductPlan = { product: "Test", slices: [], }; @@ -572,7 +573,7 @@ test("runFinalAcceptance: gate passes + runChain returns ok:true → done", asyn }); // Tests for the exported runFinalAcceptance function -const minimalPlan: IProductPlan = { +const minimalPlan: IProductPlan = { product: "Test", slices: [ { diff --git a/packages/core/tests/boringstack-refine-prompt.test.ts b/packages/core/tests/boringstack-refine-prompt.test.ts index 6c8f1c70..858da357 100644 --- a/packages/core/tests/boringstack-refine-prompt.test.ts +++ b/packages/core/tests/boringstack-refine-prompt.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "bun:test"; import type { IFeature } from "../src/loop/greenfield/greenfield.types"; import type { ISlice } from "../src/loop/planning/plan-types"; +import { type IUiIntent } from "../src/loop/boringstack/plan-extension"; import { refinePrompt } from "../src/loop/boringstack/refine-prompt"; describe("refinePrompt", () => { @@ -377,7 +378,7 @@ describe("refinePrompt", () => { passes: false, attempts: 0, }; - const slice: ISlice = { + const slice: ISlice = { entity: { id: "Bookmark", desc: "a link", @@ -418,7 +419,7 @@ describe("refinePrompt", () => { passes: false, attempts: 0, }; - const slice: ISlice = { + const slice: ISlice = { entity: { id: "Task", desc: "a task", @@ -461,7 +462,7 @@ describe("refinePrompt", () => { passes: false, attempts: 0, }; - const slice: ISlice = { + const slice: ISlice = { entity: { id: "Bookmark", desc: "a link", @@ -499,7 +500,7 @@ describe("refinePrompt", () => { passes: false, attempts: 0, }; - const slice: ISlice = { + const slice: ISlice = { entity: { id: "NotificationPrefs", desc: "prefs", diff --git a/packages/core/tests/boringstack-testid-contract.test.ts b/packages/core/tests/boringstack-testid-contract.test.ts index ba56c907..ba8a808d 100644 --- a/packages/core/tests/boringstack-testid-contract.test.ts +++ b/packages/core/tests/boringstack-testid-contract.test.ts @@ -1,5 +1,9 @@ import { test, expect, describe } from "bun:test"; import type { IProductPlan } from "../src/loop/planning/plan-types"; +import { + boringstackUiFields, + type IUiIntent, +} from "../src/loop/boringstack/plan-extension"; import { buildTestIdGuide, checkTestIds, @@ -63,7 +67,7 @@ const allFkEntity: IEntityAcceptance = { }; // Create a test entity with fields, shows, and a parent relationship -const testPlan: IProductPlan = { +const testPlan: IProductPlan = { product: "Test", slices: [ { @@ -92,7 +96,7 @@ const testPlan: IProductPlan = { ], }; -const spec = planToAcceptanceSpec(testPlan); +const spec = planToAcceptanceSpec(testPlan, boringstackUiFields); const contact = spec.entities[0]; if (!contact) { diff --git a/packages/core/tests/plan-store.test.ts b/packages/core/tests/plan-store.test.ts index f76f9ef0..cc8cecb1 100644 --- a/packages/core/tests/plan-store.test.ts +++ b/packages/core/tests/plan-store.test.ts @@ -7,11 +7,22 @@ import { loadApprovedPlan, } from "../src/loop/planning/plan-store"; import type { IProductPlan } from "../src/loop/planning/plan-types"; +import { + boringstackPlanSchema, + type IUiIntent, +} from "../src/loop/boringstack/plan-extension"; import { mkdtemp, rm } from "fs/promises"; import { join } from "path"; import { tmpdir } from "os"; -const PLAN: IProductPlan = { +// These tests exercise the BoringStack plan shape (web UI intent, layout archetypes, home flag), +// so they parse/load through the BoringStack schema — the generic parser is UI-agnostic. +const parse = ( + text: string +): { plan: IProductPlan; status: "draft" | "approved" } | null => + parsePlan(text, boringstackPlanSchema); + +const PLAN: IProductPlan = { product: "A team bookmarking app.", slices: [ { @@ -42,7 +53,7 @@ const PLAN: IProductPlan = { test("plan round-trips through serialize/parse with status", () => { const text = serializePlan(PLAN, "approved"); - const parsed = parsePlan(text); + const parsed = parse(text); expect(parsed?.status).toBe("approved"); expect(parsed?.plan.slices[0]?.entity.fields.map((f) => f.name)).toEqual([ @@ -53,7 +64,7 @@ test("plan round-trips through serialize/parse with status", () => { // Spec 1B — the layout capability: a slice may declare a layout archetype + a home landing. test("plan round-trips a slice's layout archetype + home marker", () => { - const plan: IProductPlan = { + const plan: IProductPlan = { product: "Todos", slices: [ { @@ -80,7 +91,7 @@ test("plan round-trips a slice's layout archetype + home marker", () => { }, ], }; - const parsed = parsePlan(serializePlan(plan, "approved")); + const parsed = parse(serializePlan(plan, "approved")); expect(parsed?.plan.slices[0]?.ui.layout).toBe("app-sidebar"); expect(parsed?.plan.slices[0]?.ui.home).toBe(true); @@ -115,7 +126,7 @@ const planText = (slices: unknown[]): string => test("parsePlan rejects an unknown layout archetype", () => { expect( - parsePlan(planText([sliceJson("Task", { layout: "carousel" })])) + parse(planText([sliceJson("Task", { layout: "carousel" })])) ).toBeNull(); }); @@ -124,20 +135,20 @@ test("parsePlan rejects a roadmap-only archetype not yet implemented (public/app // mis-build — critically `public` implies unauthenticated but routing wraps everything in // ProtectedRoute, so it'd be authenticated. Validation gates on IMPLEMENTED_LAYOUT_ARCHETYPES. for (const layout of ["public", "app-topnav", "focused"]) { - expect(parsePlan(planText([sliceJson("Task", { layout })]))).toBeNull(); + expect(parse(planText([sliceJson("Task", { layout })]))).toBeNull(); } }); test("parsePlan rejects more than one home slice", () => { expect( - parsePlan( + parse( planText([sliceJson("A", { home: true }), sliceJson("B", { home: true })]) ) ).toBeNull(); }); test("parsePlan accepts exactly one home slice", () => { - const parsed = parsePlan( + const parsed = parse( planText([sliceJson("A", { home: true }), sliceJson("B", {})]) ); @@ -145,18 +156,18 @@ test("parsePlan accepts exactly one home slice", () => { }); test("parsePlan rejects a non-boolean home value", () => { - expect(parsePlan(planText([sliceJson("A", { home: "true" })]))).toBeNull(); - expect(parsePlan(planText([sliceJson("A", { home: 1 })]))).toBeNull(); + expect(parse(planText([sliceJson("A", { home: "true" })]))).toBeNull(); + expect(parse(planText([sliceJson("A", { home: 1 })]))).toBeNull(); }); test("parsePlan accepts zero home slices (login falls back to the scaffold default)", () => { expect( - parsePlan(planText([sliceJson("A", {}), sliceJson("B", {})])) + parse(planText([sliceJson("A", {}), sliceJson("B", {})])) ).not.toBeNull(); }); test("parsePlan accepts a slice omitting both layout and home (backward compatible)", () => { - const parsed = parsePlan(planText([sliceJson("A", {})])); + const parsed = parse(planText([sliceJson("A", {})])); expect(parsed).not.toBeNull(); expect(parsed?.plan.slices[0]?.ui.layout).toBeUndefined(); @@ -164,14 +175,14 @@ test("parsePlan accepts a slice omitting both layout and home (backward compatib }); test("a malformed artifact parses to null (reject-by-default)", () => { - expect(parsePlan("not a plan")).toBeNull(); + expect(parse("not a plan")).toBeNull(); }); test("writePlan and readPlan round-trip to disk", async () => { const tmpDir = await mkdtemp("/tmp/tsforge-test-"); await writePlan(tmpDir, PLAN, "draft"); - const result = await readPlan(tmpDir); + const result = await readPlan(tmpDir, boringstackPlanSchema); expect(result?.status).toBe("draft"); expect(result?.plan.product).toBe("A team bookmarking app."); @@ -180,7 +191,7 @@ test("writePlan and readPlan round-trip to disk", async () => { test("readPlan returns null when no plan exists", async () => { const tmpDir = await mkdtemp("/tmp/tsforge-test-"); - const result = await readPlan(tmpDir); + const result = await readPlan(tmpDir, boringstackPlanSchema); expect(result).toBeNull(); }); @@ -193,7 +204,7 @@ status: draft {"slices": []} \`\`\``; - expect(parsePlan(malformed)).toBeNull(); + expect(parse(malformed)).toBeNull(); }); test("parsePlan rejects missing slices field", () => { @@ -204,7 +215,7 @@ status: draft {"product": "test"} \`\`\``; - expect(parsePlan(malformed)).toBeNull(); + expect(parse(malformed)).toBeNull(); }); test("parsePlan rejects missing frontmatter", () => { @@ -212,7 +223,7 @@ test("parsePlan rejects missing frontmatter", () => { {"product": "test", "slices": []} \`\`\``; - expect(parsePlan(malformed)).toBeNull(); + expect(parse(malformed)).toBeNull(); }); test("parsePlan rejects malformed JSON block", () => { @@ -223,7 +234,7 @@ status: draft {invalid json} \`\`\``; - expect(parsePlan(malformed)).toBeNull(); + expect(parse(malformed)).toBeNull(); }); test("parsePlan rejects invalid status value", () => { @@ -234,7 +245,7 @@ status: invalid {"product": "test", "slices": []} \`\`\``; - expect(parsePlan(malformed)).toBeNull(); + expect(parse(malformed)).toBeNull(); }); test("parsePlan rejects slice missing entity", () => { @@ -253,7 +264,7 @@ status: draft } \`\`\``; - expect(parsePlan(malformed)).toBeNull(); + expect(parse(malformed)).toBeNull(); }); test("parsePlan rejects slice missing verification", () => { @@ -278,7 +289,7 @@ status: draft } \`\`\``; - expect(parsePlan(malformed)).toBeNull(); + expect(parse(malformed)).toBeNull(); }); test("parsePlan rejects verification with empty mustNotHappen array", () => { @@ -315,7 +326,7 @@ status: draft } \`\`\``; - expect(parsePlan(malformed)).toBeNull(); + expect(parse(malformed)).toBeNull(); }); test("parsePlan rejects ui.screens with invalid string value", () => { @@ -352,7 +363,7 @@ status: draft } \`\`\``; - expect(parsePlan(malformed)).toBeNull(); + expect(parse(malformed)).toBeNull(); }); test("parsePlan rejects ui.screens with non-string value", () => { @@ -389,7 +400,7 @@ status: draft } \`\`\``; - expect(parsePlan(malformed)).toBeNull(); + expect(parse(malformed)).toBeNull(); }); test("parsePlan rejects entity with empty id", () => { @@ -426,7 +437,7 @@ status: draft } \`\`\``; - expect(parsePlan(malformed)).toBeNull(); + expect(parse(malformed)).toBeNull(); }); test("parsePlan rejects field with empty name", () => { @@ -463,7 +474,7 @@ status: draft } \`\`\``; - expect(parsePlan(malformed)).toBeNull(); + expect(parse(malformed)).toBeNull(); }); test("loadApprovedPlan returns null for a draft, the plan when approved", async () => { @@ -471,9 +482,11 @@ test("loadApprovedPlan returns null for a draft, the plan when approved", async try { await writePlan(dir, PLAN, "draft"); - expect(await loadApprovedPlan(dir)).toBeNull(); + expect(await loadApprovedPlan(dir, boringstackPlanSchema)).toBeNull(); await writePlan(dir, PLAN, "approved"); - expect((await loadApprovedPlan(dir))?.slices.length).toBe(1); + expect( + (await loadApprovedPlan(dir, boringstackPlanSchema))?.slices.length + ).toBe(1); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/packages/core/tests/propose-plan.test.ts b/packages/core/tests/propose-plan.test.ts index 9d5e0656..d6f47271 100644 --- a/packages/core/tests/propose-plan.test.ts +++ b/packages/core/tests/propose-plan.test.ts @@ -3,17 +3,39 @@ import { proposePlan, parsePlanJson, stripReservedSlices, - PLANNER_EXAMPLE, - PLANNER_SYSTEM, } from "../src/loop/planning/propose-plan"; import { BORINGSTACK_PLANNER_GUIDANCE, BORINGSTACK_RESERVED_ENTITY_IDS, } from "../src/loop/boringstack/planning"; -import type { IProductPlan, ISlice } from "../src/loop/planning/plan-types"; +import { + boringstackPlanSchema, + isBoringstackUiIntent, + PLANNER_EXAMPLE, + PLANNER_SYSTEM, + type IUiIntent, +} from "../src/loop/boringstack/plan-extension"; +import type { + IProductPlan, + ISlice, + IPlanConstraints, +} from "../src/loop/planning/plan-types"; import { isProductPlan } from "../src/loop/planning/plan-store"; import type { IProvider } from "../src/inference"; +// The BoringStack plan schema specializes the generic planner/parser to the web UI intent — these +// thin wrappers inject it so each call site reads like the pre-generic API. +const runPropose = ( + deps: { planner: IProvider }, + input: { description: string; mockups?: readonly string[] }, + constraints?: IPlanConstraints +): Promise | null> => + proposePlan(deps, input, boringstackPlanSchema, constraints); +const runParse = (raw: string): IProductPlan | null => + parsePlanJson(raw, isBoringstackUiIntent, boringstackPlanSchema.extraCheck); +const isPlan = (value: unknown): value is IProductPlan => + isProductPlan(value, isBoringstackUiIntent, boringstackPlanSchema.extraCheck); + const bookmarkSlice = { entity: { id: "Bookmark", @@ -33,12 +55,12 @@ const bookmarkSlice = { mustNotHappen: ["no url"], acceptanceCheck: "bun test", }, -} satisfies ISlice; +} satisfies ISlice; const mockPlan = { product: "A bookmarking app.", slices: [bookmarkSlice], -} satisfies IProductPlan; +} satisfies IProductPlan; test("proposePlan turns a product description into a structured plan", async () => { const planner: IProvider = { @@ -48,7 +70,7 @@ test("proposePlan turns a product description into a structured plan", async () }), }; - const plan = await proposePlan( + const plan = await runPropose( { planner }, { description: "a bookmarking app" } ); @@ -61,7 +83,7 @@ test("a non-JSON planner reply yields null", async () => { complete: async () => ({ content: "sorry", toolCalls: [] }), }; - expect(await proposePlan({ planner: bad }, { description: "x" })).toBeNull(); + expect(await runPropose({ planner: bad }, { description: "x" })).toBeNull(); }); test("validation failure triggers retry with higher temperature, succeeding on second reply", async () => { @@ -83,7 +105,7 @@ test("validation failure triggers retry with higher temperature, succeeding on s }, }; - const plan = await proposePlan( + const plan = await runPropose( { planner: retryingPlanner }, { description: "test" } ); @@ -102,7 +124,7 @@ test("validation failure on both attempts yields null", async () => { }, }; - const plan = await proposePlan( + const plan = await runPropose( { planner: failingPlanner }, { description: "test" } ); @@ -128,7 +150,7 @@ test("proposePlan includes mockup refs in user message", async () => { }, }; - await proposePlan( + await runPropose( { planner: capturingPlanner }, { description: "test app", @@ -144,19 +166,19 @@ test("parsePlanJson extracts JSON from fenced code blocks", () => { const fenced = `\`\`\`json ${JSON.stringify(mockPlan)} \`\`\``; - const result = parsePlanJson(fenced); + const result = runParse(fenced); expect(result?.slices[0]?.entity.id).toBe("Bookmark"); }); test("parsePlanJson rejects invalid plan shape", () => { const invalid = JSON.stringify({ product: "test" }); // missing slices - const result = parsePlanJson(invalid); + const result = runParse(invalid); expect(result).toBeNull(); }); -function authSlice(id: string): ISlice { +function authSlice(id: string): ISlice { return { entity: { id, @@ -180,7 +202,7 @@ function authSlice(id: string): ISlice { } /** Build a planner that returns exactly the given slices. */ -function plannerOf(slices: ISlice[]): IProvider { +function plannerOf(slices: ISlice[]): IProvider { return { complete: async () => ({ content: JSON.stringify({ product: "p", slices }), @@ -235,7 +257,7 @@ test("STACK-AGNOSTIC: with NO constraints, proposePlan does NOT strip a User sli // The generic planner must never assume a stack ships auth. A plain build that // legitimately needs a User entity keeps it — the bug that leaked BoringStack // assumptions into the core planner. - const plan = await proposePlan( + const plan = await runPropose( { planner: plannerOf([authSlice("User"), bookmarkSlice]) }, { description: "an app with real users" } ); @@ -253,7 +275,7 @@ test("STACK-AGNOSTIC: the base system prompt says nothing about auth being provi }, }; - await proposePlan({ planner }, { description: "x" }); // no constraints + await runPropose({ planner }, { description: "x" }); // no constraints expect(system).not.toContain("ALREADY PROVIDES authentication"); expect(system).not.toContain("auth surface"); @@ -262,7 +284,7 @@ test("STACK-AGNOSTIC: the base system prompt says nothing about auth being provi test("stripping is SURFACED via onStripped, not silent", async () => { const dropped: string[][] = []; - await proposePlan( + await runPropose( { planner: plannerOf([authSlice("User"), bookmarkSlice]) }, { description: "bookmarks" }, { ...BS, onStripped: (ids) => dropped.push([...ids]) } @@ -272,7 +294,7 @@ test("stripping is SURFACED via onStripped, not silent", async () => { }); test("BoringStack opt-in strips a redundant User slice (the live bookmark-app collision)", async () => { - const plan = await proposePlan( + const plan = await runPropose( { planner: plannerOf([authSlice("User"), bookmarkSlice]) }, { description: "bookmarks" }, BS @@ -291,7 +313,7 @@ test("BoringStack opt-in appends its auth guidance to the system prompt", async }, }; - await proposePlan({ planner }, { description: "x" }, BS); + await runPropose({ planner }, { description: "x" }, BS); expect(system).toContain("BoringStack"); expect(system).toContain("ALREADY PROVIDES authentication"); @@ -313,7 +335,7 @@ test("BoringStack opt-in: an all-auth plan on BOTH attempts strips to NULL (fini }, }; - const plan = await proposePlan({ planner }, { description: "just auth" }, BS); + const plan = await runPropose({ planner }, { description: "just auth" }, BS); expect(plan).toBeNull(); // An all-auth first attempt is retried once (a fresh try may yield real slices). @@ -341,7 +363,7 @@ test("BoringStack opt-in: an all-auth first attempt RETRIES and recovers a real }, }; - const plan = await proposePlan({ planner }, { description: "bookmarks" }, BS); + const plan = await runPropose({ planner }, { description: "bookmarks" }, BS); expect(calls).toBe(2); expect(plan?.slices.map((s) => s.entity.id)).toEqual(["Bookmark"]); @@ -366,7 +388,7 @@ test("BoringStack opt-in: stripping also applies on the temperature-0.7 retry pa }, }; - const plan = await proposePlan({ planner }, { description: "bookmarks" }, BS); + const plan = await runPropose({ planner }, { description: "bookmarks" }, BS); expect(call).toBe(2); expect(plan?.slices.map((s) => s.entity.id)).toEqual(["Bookmark"]); @@ -385,8 +407,8 @@ test("PLANNER_EXAMPLE (the shape shown to the model) is itself a valid plan", () // example's shape, the contract we advertise diverges from what the parser // accepts — and the live model dutifully copies the broken shape. Guard it: // the worked example must round-trip through the same strict guard. - expect(isProductPlan(PLANNER_EXAMPLE)).toBe(true); - expect(parsePlanJson(JSON.stringify(PLANNER_EXAMPLE))).not.toBeNull(); + expect(isPlan(PLANNER_EXAMPLE)).toBe(true); + expect(runParse(JSON.stringify(PLANNER_EXAMPLE))).not.toBeNull(); }); test("the planner contract surfaces layout + home so plans can actually use the capability", () => { @@ -402,7 +424,7 @@ test("the planner contract surfaces layout + home so plans can actually use the // Exactly one home in the worked example, and it's an app-sidebar primary view. Widen to // IProductPlan first: PLANNER_EXAMPLE's concrete literal type narrows `home` to `true`, which // eslint flags as an always-truthy condition; the interface type restores `boolean | undefined`. - const example: IProductPlan = PLANNER_EXAMPLE; + const example: IProductPlan = PLANNER_EXAMPLE; const homeCount = example.slices.filter((s) => s.ui.home === true).length; const home = example.slices.find((s) => s.ui.home === true); diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index 2254cc4a..a351c1ac 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -220,7 +220,7 @@ const REPL_TS = join(import.meta.dir, "..", "src", "cli", "repl.ts"); // The exact wiring, pinned as the LAST statement (`$$$BODY` absorbs everything before it) of the // runLine arrow — identified by its exact signature. All args are literal (no metavariables). const ANCHORED = - "async (line: string): Promise => { $$$BODY await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line)); }"; + "async (line: string): Promise => { $$$BODY await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d, boringstackPlanSchema)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line)); }"; /** Count structural matches of `pattern` over `file` via ast-grep. ast-grep exits 0 with matches * and 1 with none (both print valid JSON — `[…]` / `[]`), so success is "stdout parses to a JSON @@ -280,7 +280,7 @@ describe("the REPL line handler wires greenfieldOrSend (ast-grep structural guar const wrapArrow = (body: string): string => `const runLine = async (line: string): Promise => {\n ${body}\n};\n`; const CORRECT_CALL = - "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line))"; + "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d, boringstackPlanSchema)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line))"; test("SANITY: the correct arrow shape matches (so the negatives fail for the right reason)", async () => { expect(await countOn(wrapArrow(`${CORRECT_CALL};`))).toBe(1); @@ -289,14 +289,14 @@ describe("the REPL line handler wires greenfieldOrSend (ast-grep structural guar // Shape bypasses — a different node for one of the three args, so ANCHORED never matches. test("rejects a block-body onGreenfield that plans then sends", async () => { const bypass = - "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => { runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack); runSend(line); }, () => runSend(line))"; + "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d, boringstackPlanSchema)) !== null, (stack) => { runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack); runSend(line); }, () => runSend(line))"; expect(await countOn(wrapArrow(`${bypass};`))).toBe(0); }); test("rejects a send chained onto runGreenfieldPlanning (.finally)", async () => { const bypass = - "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack).finally(() => runSend(line)), () => runSend(line))"; + "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d, boringstackPlanSchema)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack).finally(() => runSend(line)), () => runSend(line))"; expect(await countOn(wrapArrow(`${bypass};`))).toBe(0); }); diff --git a/packages/core/tests/run-planning.test.ts b/packages/core/tests/run-planning.test.ts index 0254f9b7..12d9d180 100644 --- a/packages/core/tests/run-planning.test.ts +++ b/packages/core/tests/run-planning.test.ts @@ -6,6 +6,10 @@ import { runPlanning } from "../src/loop/planning/run-planning"; import { readPlan } from "../src/loop/planning/plan-store"; import type { IProvider } from "../src/inference"; import type { IPlanningDeps } from "../src/loop/planning/run-planning"; +import { + boringstackPlanSchema, + type IUiIntent, +} from "../src/loop/boringstack/plan-extension"; const mockPlan = { product: "A bookmarking app.", @@ -46,7 +50,8 @@ test("runPlanning writes an approved plan when the human approves", async () => const dir = await mkdtemp(join(tmpdir(), "plan-")); try { - const deps: IPlanningDeps = { + const deps: IPlanningDeps = { + schema: boringstackPlanSchema, planner: fakePlanner(), describe: async () => ({ description: "a bookmarking app" }), review: async () => ({ action: "approve" as const }), @@ -54,7 +59,9 @@ test("runPlanning writes an approved plan when the human approves", async () => }; expect(await runPlanning(dir, deps)).toBe("approved"); - expect((await readPlan(dir))?.status).toBe("approved"); + expect((await readPlan(dir, boringstackPlanSchema))?.status).toBe( + "approved" + ); } finally { await rm(dir, { recursive: true, force: true }); } @@ -64,7 +71,8 @@ test("runPlanning returns cancelled when the human cancels", async () => { const dir = await mkdtemp(join(tmpdir(), "plan-")); try { - const deps: IPlanningDeps = { + const deps: IPlanningDeps = { + schema: boringstackPlanSchema, planner: fakePlanner(), describe: async () => ({ description: "a bookmarking app" }), review: async () => ({ action: "cancel" as const }), @@ -72,7 +80,7 @@ test("runPlanning returns cancelled when the human cancels", async () => { }; expect(await runPlanning(dir, deps)).toBe("cancelled"); - expect(await readPlan(dir)).toBeNull(); + expect(await readPlan(dir, boringstackPlanSchema)).toBeNull(); } finally { await rm(dir, { recursive: true, force: true }); } @@ -84,7 +92,8 @@ test("runPlanning re-proposes on revise and approves on second review", async () try { let reviewCount = 0; - const deps: IPlanningDeps = { + const deps: IPlanningDeps = { + schema: boringstackPlanSchema, planner: fakePlanner(), describe: async () => ({ description: "a bookmarking app" }), review: async () => { @@ -100,7 +109,9 @@ test("runPlanning re-proposes on revise and approves on second review", async () }; expect(await runPlanning(dir, deps)).toBe("approved"); - expect((await readPlan(dir))?.status).toBe("approved"); + expect((await readPlan(dir, boringstackPlanSchema))?.status).toBe( + "approved" + ); expect(reviewCount).toBe(2); } finally { await rm(dir, { recursive: true, force: true }); @@ -113,7 +124,8 @@ test("runPlanning cancels after hitting the revision cap", async () => { try { let reviewCount = 0; - const deps: IPlanningDeps = { + const deps: IPlanningDeps = { + schema: boringstackPlanSchema, planner: fakePlanner(), describe: async () => ({ description: "a bookmarking app" }), review: async () => { @@ -125,7 +137,7 @@ test("runPlanning cancels after hitting the revision cap", async () => { }; expect(await runPlanning(dir, deps)).toBe("cancelled"); - expect(await readPlan(dir)).toBeNull(); + expect(await readPlan(dir, boringstackPlanSchema)).toBeNull(); expect(reviewCount).toBeLessThanOrEqual(5); } finally { await rm(dir, { recursive: true, force: true }); @@ -142,7 +154,8 @@ test("runPlanning cancels when proposePlan returns null", async () => { const messages: string[] = []; - const deps: IPlanningDeps = { + const deps: IPlanningDeps = { + schema: boringstackPlanSchema, planner: nullPlanner, describe: async () => ({ description: "a bookmarking app" }), review: async () => ({ action: "cancel" as const }), @@ -171,7 +184,8 @@ test("runPlanning forwards constraints (guidance) to the planner", async () => { }, }; - const deps: IPlanningDeps = { + const deps: IPlanningDeps = { + schema: boringstackPlanSchema, planner: capturingPlanner, constraints: { guidance: "STACK-MARKER-XYZ" }, describe: async () => ({ description: "a bookmarking app" }), @@ -225,7 +239,8 @@ test("runPlanning forwards reservedEntities + onStripped (a reserved slice is dr }; const dropped: string[][] = []; - const deps: IPlanningDeps = { + const deps: IPlanningDeps = { + schema: boringstackPlanSchema, planner, constraints: { reservedEntities: new Set(["user"]), @@ -238,7 +253,7 @@ test("runPlanning forwards reservedEntities + onStripped (a reserved slice is dr expect(await runPlanning(dir, deps)).toBe("approved"); // The reserved slice was dropped from the written plan… - const written = await readPlan(dir); + const written = await readPlan(dir, boringstackPlanSchema); expect(written?.plan.slices.map((s) => s.entity.id)).toEqual(["Bookmark"]); // …AND the drop was surfaced through the reporter (never silent). From 131bc1ee391e50fd9889f9a87a3c0e623744dfe4 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 04:34:07 +0200 Subject: [PATCH 38/53] refactor(core): harden the WS3 plan-schema seam (panel r1 findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dead-code: remove the unused IPlanSchema.example (PLANNER_SYSTEM already embeds the serialized example) — no more lying contract. - scope-bypass (soundness): re-apply the schema's extraCheck AFTER stripReservedSlices in proposePlan, so a transform can't leave a cross-slice invariant false. - wrong-idiom (multi-adapter): IStackAdapter now carries planSchema (type-erased IPlanSchema); the greenfield flow (repl) drives planning AND the approved-plan check through the RESOLVED adapter's schema, not a hardcoded boringstackPlanSchema. Adds boringstackPlanSchemaErased (validateUi erases directly; extraCheck re-narrows each opaque ui). - missing-test: new generic-plan-seam.test.ts proves core defers UI + cross-slice validation to a NON-boringstack (game) schema — a hardcoded web check would fail it. New plan-extension.test.ts covers isBoringstackUiIntent edges, the ≤1-home extraCheck, erased-schema parity, and boringstackUiFields. - complexity/idiom: isProductPlan validates slices ONCE (single loop collecting narrowed slices) instead of every()+filter(). - dead-code: build.ts uses the BoringstackProductPlan alias. - honest scope: acceptance IAcceptanceUiFields comment no longer claims screens/nav leave core — the plan SPINE is reclaimed; acceptance generation stays web-shaped (boringstack-only), relocation tracked as follow-up. validate green (3282 pass, 0 fail). --- packages/core/src/cli/repl.ts | 16 ++- .../src/loop/acceptance/acceptance-spec.ts | 11 +- packages/core/src/loop/boringstack/build.ts | 5 +- .../src/loop/boringstack/plan-extension.ts | 19 ++- .../core/src/loop/boringstack/planning.ts | 2 + packages/core/src/loop/planning/plan-store.ts | 25 ++-- packages/core/src/loop/planning/plan-types.ts | 9 +- .../core/src/loop/planning/propose-plan.ts | 13 +- .../core/src/loop/planning/stack-adapter.ts | 10 +- packages/core/tests/generic-plan-seam.test.ts | 73 ++++++++++ packages/core/tests/plan-extension.test.ts | 135 ++++++++++++++++++ .../core/tests/repl-greenfield-stack.test.ts | 9 +- packages/core/tests/stack-adapter.test.ts | 1 + 13 files changed, 292 insertions(+), 36 deletions(-) create mode 100644 packages/core/tests/generic-plan-seam.test.ts create mode 100644 packages/core/tests/plan-extension.test.ts diff --git a/packages/core/src/cli/repl.ts b/packages/core/src/cli/repl.ts index c7d157ac..655b2efc 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -49,7 +49,6 @@ import { type IStackAdapter, } from "../loop/planning/stack-adapter"; import { boringstackStackAdapter } from "../loop/boringstack/planning"; -import { boringstackPlanSchema } from "../loop/boringstack/plan-extension"; import { loadApprovedPlan } from "../loop/planning/plan-store"; import { loadRecipes } from "../config/recipes"; import { loadAgentSpecs } from "../config/agent-specs"; @@ -177,7 +176,7 @@ const STACK_ADAPTERS: readonly IStackAdapter[] = [boringstackStackAdapter]; export async function resolveGreenfieldStack( dir: string, adapters: readonly IStackAdapter[], - hasApprovedPlan: (dir: string) => Promise + hasApprovedPlan: (dir: string, stack: IStackAdapter) => Promise ): Promise { const stack = await resolveStackAdapter(dir, adapters); @@ -185,7 +184,9 @@ export async function resolveGreenfieldStack( return null; } - return (await hasApprovedPlan(dir)) ? null : stack; + // hasApprovedPlan receives the RESOLVED adapter, so the approved-plan check parses through the + // SAME stack's schema that will drive planning — no hardcoded stack. + return (await hasApprovedPlan(dir, stack)) ? null : stack; } /** @@ -215,7 +216,7 @@ export function greenfieldConstraints( export async function greenfieldOrSend( dir: string, adapters: readonly IStackAdapter[], - hasApprovedPlan: (dir: string) => Promise, + hasApprovedPlan: (dir: string, stack: IStackAdapter) => Promise, onGreenfield: (stack: IStackAdapter) => Promise, onSend: () => Promise ): Promise { @@ -252,8 +253,9 @@ async function runGreenfieldPlanning( const result = await runPlanning(dir, { planner: plannerProvider, - // The stack's plan schema (web UI shape, prompt, validator) — BoringStack today. - schema: boringstackPlanSchema, + // The plan schema comes from the RESOLVED adapter — a project is planned + validated by the + // stack that detected it, not a hardcoded one. + schema: stack.planSchema, // We only reach here when this stack adapter detected the project, so its // reserved-slice rule always applies (no gap) and every drop is surfaced. constraints: greenfieldConstraints(stack, echo), @@ -955,7 +957,7 @@ export async function repl(args: ICliArgs): Promise { await greenfieldOrSend( args.dir, STACK_ADAPTERS, - async (d) => (await loadApprovedPlan(d, boringstackPlanSchema)) !== null, + async (d, s) => (await loadApprovedPlan(d, s.planSchema)) !== null, (stack) => runGreenfieldPlanning( args.dir, diff --git a/packages/core/src/loop/acceptance/acceptance-spec.ts b/packages/core/src/loop/acceptance/acceptance-spec.ts index c0982fcd..3ac4e852 100644 --- a/packages/core/src/loop/acceptance/acceptance-spec.ts +++ b/packages/core/src/loop/acceptance/acceptance-spec.ts @@ -8,9 +8,14 @@ import type { ITestIds, } from "./acceptance.types"; -/** The UI fields acceptance generation needs from a slice's `ui` — nav label, shown fields, and - * the screen list. Core is generic over the UI-intent type; the stack adapter injects an - * extractor (`uiFields`) mapping its concrete UI intent to these, so core never names a web shape. */ +/** The UI fields acceptance generation projects from a slice's `ui` — nav label, shown fields, and + * the screen list. HONEST SCOPE: `nav`/`shows`/`screens` are a WEB/SaaS-flavoured projection, and + * this whole acceptance-generation module is consumed only by the BoringStack adapter (build.ts, + * testid-contract, e2e-generator). The `uiFields` extractor removes core's dependence on the + * concrete UI-intent TYPE (that leak is what WS3 reclaimed from the plan spine), but it does NOT + * make e2e generation stack-neutral — a UI-less adapter (Phaser) would still have to manufacture + * these. Relocating the acceptance subsystem into `loop/boringstack/` is tracked as follow-up; it + * is downstream of the plan spine, not part of it. */ export interface IAcceptanceUiFields { readonly nav: string; readonly shows: readonly string[]; diff --git a/packages/core/src/loop/boringstack/build.ts b/packages/core/src/loop/boringstack/build.ts index 7bfa6648..8ed903f9 100644 --- a/packages/core/src/loop/boringstack/build.ts +++ b/packages/core/src/loop/boringstack/build.ts @@ -23,10 +23,11 @@ import type { Reporter, IHandoff, EscalationRung } from "../loop.types"; import { slicesToFeatures, invalidEntityIds } from "./plan-resources"; import { toCamelCase } from "./case"; import { loadApprovedPlan } from "../planning/plan-store"; -import type { ISlice, IProductPlan } from "../planning/plan-types"; +import type { ISlice } from "../planning/plan-types"; import { boringstackPlanSchema, boringstackUiFields, + type BoringstackProductPlan, type IUiIntent, } from "./plan-extension"; import { planToAcceptanceSpec } from "../acceptance/acceptance-spec"; @@ -43,7 +44,7 @@ import { readHostPorts, hostPortOr } from "../../scaffold"; import { FLAG_ON, ENV_FLAG } from "../../config/config.constants"; /** BoringStack builds a concrete web plan — the generic spine specialized to IUiIntent. */ -type BsPlan = IProductPlan; +type BsPlan = BoringstackProductPlan; type BsSlice = ISlice; /** Apply BoringStack's DETERMINISTIC auto-fixes over both apps before the gate: diff --git a/packages/core/src/loop/boringstack/plan-extension.ts b/packages/core/src/loop/boringstack/plan-extension.ts index 6b39484a..1c37c1f4 100644 --- a/packages/core/src/loop/boringstack/plan-extension.ts +++ b/packages/core/src/loop/boringstack/plan-extension.ts @@ -201,13 +201,30 @@ ${JSON.stringify(PLANNER_EXAMPLE, null, 2)}`; * is the app home (the post-login landing). */ export const boringstackPlanSchema: IPlanSchema = { + // PLANNER_SYSTEM already embeds the serialized PLANNER_EXAMPLE, so the seam needs no separate + // example field. PLANNER_EXAMPLE stays exported for its own regression test. system: PLANNER_SYSTEM, - example: PLANNER_EXAMPLE, validateUi: isBoringstackUiIntent, extraCheck: (plan) => plan.slices.filter((s) => s.ui.home === true).length <= 1, }; +/** + * The SAME schema, TYPE-ERASED to `IPlanSchema` for the heterogeneous `IStackAdapter` + * registry (a concrete `IPlanSchema` isn't assignable to `IPlanSchema` because + * `extraCheck`'s parameter is contravariant). `validateUi` erases directly (a `v is IUiIntent` + * guard satisfies `v is unknown`); `extraCheck` re-narrows each slice's opaque `ui` with the same + * guard, so the runtime behaviour is identical. The typed `boringstackPlanSchema` stays for the + * BoringStack build path, which needs `IProductPlan`. + */ +export const boringstackPlanSchemaErased: IPlanSchema = { + system: PLANNER_SYSTEM, + validateUi: isBoringstackUiIntent, + extraCheck: (plan) => + plan.slices.filter((s) => isBoringstackUiIntent(s.ui) && s.ui.home === true) + .length <= 1, +}; + /** * Extract the acceptance UI fields (nav / shows / screens) from a BoringStack UI intent — the * injected seam for the generic `planToAcceptanceSpec`, so the web UI shape stays out of core's diff --git a/packages/core/src/loop/boringstack/planning.ts b/packages/core/src/loop/boringstack/planning.ts index fe766cae..f5cf3e9b 100644 --- a/packages/core/src/loop/boringstack/planning.ts +++ b/packages/core/src/loop/boringstack/planning.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { isRecord } from "../../lib/guards"; import type { IPlanConstraints } from "../planning/plan-types"; import type { IStackAdapter } from "../planning/stack-adapter"; +import { boringstackPlanSchemaErased } from "./plan-extension"; /** STACK-SPECIFIC planner guidance for BoringStack (kept OUT of the generic * planner). Appended to the system prompt only for a BoringStack project. The @@ -92,4 +93,5 @@ export const boringstackStackAdapter: IStackAdapter = { id: "boringstack", detect: (dir) => isBoringstackProject(dir), planConstraints: boringstackPlanConstraints, + planSchema: boringstackPlanSchemaErased, }; diff --git a/packages/core/src/loop/planning/plan-store.ts b/packages/core/src/loop/planning/plan-store.ts index a74887cd..809f69e5 100644 --- a/packages/core/src/loop/planning/plan-store.ts +++ b/packages/core/src/loop/planning/plan-store.ts @@ -195,21 +195,20 @@ export function isProductPlan( return false; } - if (!value.slices.every((s) => isSlice(s, validateUi))) { - return false; - } + // Validate every slice ONCE, collecting the narrowed slices so the cross-slice `extraCheck` gets + // a typed IProductPlan without a second validation pass or a cast. A single failure rejects. + const slices: ISlice[] = []; - // `value` is now shape-verified as IProductPlan; the narrowing predicate lets us hand it to - // the stack's cross-slice rule without a cast. - const plan: IProductPlan = { - product: value.product, - slices: value.slices.filter((s): s is ISlice => - isSlice(s, validateUi) - ), - }; + for (const s of value.slices) { + if (!isSlice(s, validateUi)) { + return false; + } - if (extraCheck !== undefined && !extraCheck(plan)) { - return false; + slices.push(s); + } + + if (extraCheck !== undefined) { + return extraCheck({ product: value.product, slices }); } return true; diff --git a/packages/core/src/loop/planning/plan-types.ts b/packages/core/src/loop/planning/plan-types.ts index 4351612a..1ea07b67 100644 --- a/packages/core/src/loop/planning/plan-types.ts +++ b/packages/core/src/loop/planning/plan-types.ts @@ -43,13 +43,14 @@ export interface IProductPlan { * a trivial pass-through. */ export interface IPlanSchema { - /** System-prompt text teaching the model this stack's exact plan/UI shape. */ + /** System-prompt text teaching the model this stack's exact plan/UI shape. The adapter is + * responsible for embedding any worked example INTO this string (core only feeds `system` to + * the model) — so there is no separate `example` field to keep in sync. */ readonly system: string; - /** A complete, valid example plan (serialized into the prompt) pinning the output shape. */ - readonly example: IProductPlan; /** Validates a slice's `ui` field at the parse boundary (reject-by-default). */ readonly validateUi: (value: unknown) => value is TUi; - /** Optional cross-slice rule (e.g. "≤1 home"); returns false to reject the plan. */ + /** Optional cross-slice rule (e.g. "≤1 home"); returns false to reject the plan. Re-checked + * after reserved-slice stripping, so a transform can't leave the invariant false. */ readonly extraCheck?: (plan: IProductPlan) => boolean; } diff --git a/packages/core/src/loop/planning/propose-plan.ts b/packages/core/src/loop/planning/propose-plan.ts index 0ae03b9a..fc22a69b 100644 --- a/packages/core/src/loop/planning/propose-plan.ts +++ b/packages/core/src/loop/planning/propose-plan.ts @@ -95,7 +95,18 @@ export async function proposePlan( const stripped = stripReservedSlices(parsed, reservedEntities); - return stripped.slices.length > 0 ? stripped : null; + if (stripped.slices.length === 0) { + return null; + } + + // Re-apply the schema's cross-slice rule to the STRIPPED plan: stripping can invalidate an + // invariant that held on the full plan (e.g. removing the slice that satisfied it), so a + // transformed plan is never returned unchecked. + if (schema.extraCheck !== undefined && !schema.extraCheck(stripped)) { + return null; + } + + return stripped; }; // First attempt: temperature 0 (deterministic) diff --git a/packages/core/src/loop/planning/stack-adapter.ts b/packages/core/src/loop/planning/stack-adapter.ts index 68341ef8..88a76264 100644 --- a/packages/core/src/loop/planning/stack-adapter.ts +++ b/packages/core/src/loop/planning/stack-adapter.ts @@ -1,4 +1,4 @@ -import type { IPlanConstraints } from "./plan-types"; +import type { IPlanConstraints, IPlanSchema } from "./plan-types"; /** * A STACK adapter as the generic planner/CLI sees it. The core greenfield flow knows @@ -26,6 +26,14 @@ export interface IStackAdapter { planConstraints( onStripped: (droppedEntityIds: readonly string[]) => void ): IPlanConstraints; + /** + * The stack's plan schema (prompt + UI validator + cross-slice rule), TYPE-ERASED to + * `IPlanSchema` so a heterogeneous adapter registry is well-typed. The greenfield flow + * drives the planner + parses/loads plans through THIS schema (so a project is planned and + * validated by the adapter that detected it — not a hardcoded stack). The adapter keeps a + * concretely-typed schema for its own build path; this is the same runtime schema, erased. + */ + readonly planSchema: IPlanSchema; } /** diff --git a/packages/core/tests/generic-plan-seam.test.ts b/packages/core/tests/generic-plan-seam.test.ts new file mode 100644 index 00000000..3e4b277b --- /dev/null +++ b/packages/core/tests/generic-plan-seam.test.ts @@ -0,0 +1,73 @@ +import { test, expect, describe } from "bun:test"; +import { isProductPlan } from "../src/loop/planning/plan-store"; +import { isRecord } from "../src/lib/guards"; + +// Proves core validates the STRUCTURAL SPINE only and DEFERS the UI shape + any cross-slice rule to +// the INJECTED schema — with a NON-BoringStack (game-shaped) UI intent. If core still carried a +// hardcoded web UI check (screens/nav/…), the game plans below would be rejected and this test +// would fail; if it ignored the injected validateUi, the malformed-ui plan would be accepted. +interface IGameUi { + readonly scene: string; +} + +const isGameUi = (v: unknown): v is IGameUi => + isRecord(v) && typeof v.scene === "string"; + +// A non-web cross-slice rule: at most two scenes (nothing to do with "home"). +const atMostTwoScenes = (plan: { + slices: readonly { ui: IGameUi }[]; +}): boolean => plan.slices.length <= 2; + +const entity = { + id: "Level", + desc: "d", + fields: [], + relationships: [], + rules: [], +}; +const verification = { + mustRemainTrue: [], + mustNotHappen: ["x"], + acceptanceCheck: "x", +}; +const gameSlice = (scene: string): unknown => ({ + entity, + ui: { scene }, + verification, +}); +const webSlice = (): unknown => ({ + entity, + ui: { screens: ["list"], action: "a", shows: [], nav: "N" }, // valid WEB ui, invalid GAME ui + verification, +}); + +describe("core plan validation defers UI + cross-slice rules to the injected schema", () => { + test("accepts a plan whose ui matches the injected (game) validator", () => { + expect( + isProductPlan({ product: "g", slices: [gameSlice("a")] }, isGameUi) + ).toBe(true); + }); + + test("REJECTS a web-shaped ui under the game validator (no hardcoded web check in core)", () => { + expect( + isProductPlan({ product: "g", slices: [webSlice()] }, isGameUi) + ).toBe(false); + }); + + test("enforces the injected cross-slice extraCheck (not the boringstack '≤1 home' rule)", () => { + const two = { product: "g", slices: [gameSlice("a"), gameSlice("b")] }; + const three = { + product: "g", + slices: [gameSlice("a"), gameSlice("b"), gameSlice("c")], + }; + + expect(isProductPlan(two, isGameUi, atMostTwoScenes)).toBe(true); + expect(isProductPlan(three, isGameUi, atMostTwoScenes)).toBe(false); + }); + + test("still validates the STRUCTURAL spine (a slice missing verification is rejected)", () => { + const bad = { product: "g", slices: [{ entity, ui: { scene: "a" } }] }; + + expect(isProductPlan(bad, isGameUi)).toBe(false); + }); +}); diff --git a/packages/core/tests/plan-extension.test.ts b/packages/core/tests/plan-extension.test.ts new file mode 100644 index 00000000..d74d4188 --- /dev/null +++ b/packages/core/tests/plan-extension.test.ts @@ -0,0 +1,135 @@ +import { test, expect, describe } from "bun:test"; +import { + isBoringstackUiIntent, + boringstackUiFields, + boringstackPlanSchema, + boringstackPlanSchemaErased, + PLANNER_EXAMPLE, + IMPLEMENTED_LAYOUT_ARCHETYPES, + type IUiIntent, +} from "../src/loop/boringstack/plan-extension"; + +const validUi: IUiIntent = { + screens: ["list", "form"], + action: "add", + shows: ["title"], + nav: "Tasks", +}; + +describe("isBoringstackUiIntent", () => { + test("accepts a well-formed web UI intent", () => { + expect(isBoringstackUiIntent(validUi)).toBe(true); + }); + + test("rejects an unknown screen id", () => { + expect(isBoringstackUiIntent({ ...validUi, screens: ["carousel"] })).toBe( + false + ); + }); + + test("rejects an empty action / nav", () => { + expect(isBoringstackUiIntent({ ...validUi, action: "" })).toBe(false); + expect(isBoringstackUiIntent({ ...validUi, nav: "" })).toBe(false); + }); + + test("rejects a layout that is not IMPLEMENTED (roadmap-only is rejected, not mis-built)", () => { + for (const layout of ["public", "app-topnav", "focused"]) { + expect(isBoringstackUiIntent({ ...validUi, layout })).toBe(false); + } + + for (const layout of IMPLEMENTED_LAYOUT_ARCHETYPES) { + expect(isBoringstackUiIntent({ ...validUi, layout })).toBe(true); + } + }); + + test("rejects a non-boolean home", () => { + expect(isBoringstackUiIntent({ ...validUi, home: "true" })).toBe(false); + expect(isBoringstackUiIntent({ ...validUi, home: true })).toBe(true); + }); +}); + +const entity = { + id: "X", + desc: "d", + fields: [], + relationships: [], + rules: [], +}; +const verification = { + mustRemainTrue: [], + mustNotHappen: ["x"], + acceptanceCheck: "x", +}; +const homeSlice = (home: boolean) => ({ + entity, + ui: { ...validUi, home }, + verification, +}); +const plan = ( + slices: readonly ReturnType[] +): { product: string; slices: readonly ReturnType[] } => ({ + product: "p", + slices, +}); + +describe("boringstackPlanSchema.extraCheck (≤1 home)", () => { + test("accepts zero or one home slice, rejects two", () => { + expect( + boringstackPlanSchema.extraCheck?.( + plan([homeSlice(false), homeSlice(false)]) + ) + ).toBe(true); + expect( + boringstackPlanSchema.extraCheck?.( + plan([homeSlice(true), homeSlice(false)]) + ) + ).toBe(true); + expect( + boringstackPlanSchema.extraCheck?.( + plan([homeSlice(true), homeSlice(true)]) + ) + ).toBe(false); + }); +}); + +describe("boringstackPlanSchemaErased (type-erased parity)", () => { + test("its extraCheck matches the typed schema on the ≤1-home rule", () => { + // The erased schema re-narrows each opaque ui with isBoringstackUiIntent, so it agrees with + // the typed one — including that a non-UI slice (ui not a valid intent) contributes no home. + const nonUiSlice = { entity, ui: {}, verification }; + + expect( + boringstackPlanSchemaErased.extraCheck?.({ + product: "p", + slices: [homeSlice(true), nonUiSlice], + }) + ).toBe(true); + expect( + boringstackPlanSchemaErased.extraCheck?.({ + product: "p", + slices: [homeSlice(true), homeSlice(true)], + }) + ).toBe(false); + }); + + test("validateUi is the boringstack guard", () => { + expect(boringstackPlanSchemaErased.validateUi(validUi)).toBe(true); + expect(boringstackPlanSchemaErased.validateUi({ nope: 1 })).toBe(false); + }); +}); + +describe("boringstackUiFields", () => { + test("extracts nav / shows / screens from a UI intent", () => { + const first = PLANNER_EXAMPLE.slices[0]; + + if (!first) { + throw new Error("PLANNER_EXAMPLE has no slice"); + } + + expect(boringstackUiFields(first.ui)).toEqual({ + nav: "Tasks", + shows: ["title", "done", "dueDate"], + screens: ["list", "detail", "form"], + }); + }); +}); diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index a351c1ac..03b54328 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -19,6 +19,7 @@ const stub = (id: string, matches: boolean): IStackAdapter => ({ reservedEntities: new Set([`${id}-reserved`]), onStripped, }), + planSchema: { system: "", validateUi: (_v: unknown): _v is unknown => true }, }); const noApprovedPlan = (): Promise => Promise.resolve(false); @@ -220,7 +221,7 @@ const REPL_TS = join(import.meta.dir, "..", "src", "cli", "repl.ts"); // The exact wiring, pinned as the LAST statement (`$$$BODY` absorbs everything before it) of the // runLine arrow — identified by its exact signature. All args are literal (no metavariables). const ANCHORED = - "async (line: string): Promise => { $$$BODY await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d, boringstackPlanSchema)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line)); }"; + "async (line: string): Promise => { $$$BODY await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d, s) => (await loadApprovedPlan(d, s.planSchema)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line)); }"; /** Count structural matches of `pattern` over `file` via ast-grep. ast-grep exits 0 with matches * and 1 with none (both print valid JSON — `[…]` / `[]`), so success is "stdout parses to a JSON @@ -280,7 +281,7 @@ describe("the REPL line handler wires greenfieldOrSend (ast-grep structural guar const wrapArrow = (body: string): string => `const runLine = async (line: string): Promise => {\n ${body}\n};\n`; const CORRECT_CALL = - "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d, boringstackPlanSchema)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line))"; + "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d, s) => (await loadApprovedPlan(d, s.planSchema)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack), () => runSend(line))"; test("SANITY: the correct arrow shape matches (so the negatives fail for the right reason)", async () => { expect(await countOn(wrapArrow(`${CORRECT_CALL};`))).toBe(1); @@ -289,14 +290,14 @@ describe("the REPL line handler wires greenfieldOrSend (ast-grep structural guar // Shape bypasses — a different node for one of the three args, so ANCHORED never matches. test("rejects a block-body onGreenfield that plans then sends", async () => { const bypass = - "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d, boringstackPlanSchema)) !== null, (stack) => { runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack); runSend(line); }, () => runSend(line))"; + "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d, s) => (await loadApprovedPlan(d, s.planSchema)) !== null, (stack) => { runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack); runSend(line); }, () => runSend(line))"; expect(await countOn(wrapArrow(`${bypass};`))).toBe(0); }); test("rejects a send chained onto runGreenfieldPlanning (.finally)", async () => { const bypass = - "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d) => (await loadApprovedPlan(d, boringstackPlanSchema)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack).finally(() => runSend(line)), () => runSend(line))"; + "await greenfieldOrSend(args.dir, STACK_ADAPTERS, async (d, s) => (await loadApprovedPlan(d, s.planSchema)) !== null, (stack) => runGreenfieldPlanning(args.dir, line, echo, rl, activeModelEntry, stack).finally(() => runSend(line)), () => runSend(line))"; expect(await countOn(wrapArrow(`${bypass};`))).toBe(0); }); diff --git a/packages/core/tests/stack-adapter.test.ts b/packages/core/tests/stack-adapter.test.ts index 0cef4a11..9cdf6452 100644 --- a/packages/core/tests/stack-adapter.test.ts +++ b/packages/core/tests/stack-adapter.test.ts @@ -20,6 +20,7 @@ const fake = (id: string, matches: boolean): IStackAdapter => ({ // guidance-only constraint (no reserved entities) — these fakes exist for resolution // order, so planConstraints is never invoked; a valid minimal value keeps types honest. planConstraints: () => ({ guidance: id }), + planSchema: { system: "", validateUi: (_v: unknown): _v is unknown => true }, }); describe("resolveStackAdapter", () => { From eed4b4ac883a0124be4a56a75ed27e5ccf52e333 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 04:56:55 +0200 Subject: [PATCH 39/53] test(core): prove the WS3 plan-schema seam (panel r2 findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - share ONE boringstackAtMostOneHome helper between the typed and erased schemas so their extraCheck bodies can never drift (was two hand-maintained copies that diverged on invalid ui) - propose-plan.test: prove proposePlan RE-APPLIES the injected extraCheck to the STRIPPED plan (reject when stripping invalidates it, accept when it holds) via a removal-sensitive custom schema — the boringstack rule can't exercise it - plan-extension.test: compare typed vs erased side-by-side on every valid plan + the documented invalid-ui divergence case (now shared → identical) - repl-greenfield-stack.test: ast-grep guard that runGreenfieldPlanning's runPlanning call carries schema: stack.planSchema (the RESOLVED adapter's), with a hardcoded-schema negative decoy --- .../src/loop/boringstack/plan-extension.ts | 28 +++++-- packages/core/tests/plan-extension.test.ts | 29 +++++-- packages/core/tests/propose-plan.test.ts | 75 +++++++++++++++++++ .../core/tests/repl-greenfield-stack.test.ts | 39 +++++++++- 4 files changed, 156 insertions(+), 15 deletions(-) diff --git a/packages/core/src/loop/boringstack/plan-extension.ts b/packages/core/src/loop/boringstack/plan-extension.ts index 1c37c1f4..7d0fc387 100644 --- a/packages/core/src/loop/boringstack/plan-extension.ts +++ b/packages/core/src/loop/boringstack/plan-extension.ts @@ -195,6 +195,23 @@ Rules for the JSON: Complete example (follow this shape precisely): ${JSON.stringify(PLANNER_EXAMPLE, null, 2)}`; +/** + * The BoringStack cross-slice rule — at most ONE slice is the app home (the post-login landing) — + * as ONE implementation shared by BOTH schemas below, so their `extraCheck` behaviour can never + * drift (a rule edit touches one place). Takes the slices' `ui` values as `unknown[]` and + * re-narrows each with `isBoringstackUiIntent` (a plain `ui` variable narrows cleanly), so it + * behaves identically whether `ui` is a statically-typed IUiIntent or an opaque `unknown` — a + * `ui` that is not a valid intent (e.g. `{ home: true }` with no screens) counts as no home in + * BOTH. In the real flow `extraCheck` runs only after every slice passed `validateUi`, so this + * only matters for direct callers, but keeping the two bodies identical removes the drift risk. + */ +function boringstackAtMostOneHome(uis: readonly unknown[]): boolean { + return ( + uis.filter((ui) => isBoringstackUiIntent(ui) && ui.home === true).length <= + 1 + ); +} + /** * BoringStack's plan schema, injected into core's generic planner + parser: the web UI-schema * prompt, the worked example, the `ui` validator, and the cross-slice rule that at most ONE slice @@ -205,24 +222,21 @@ export const boringstackPlanSchema: IPlanSchema = { // example field. PLANNER_EXAMPLE stays exported for its own regression test. system: PLANNER_SYSTEM, validateUi: isBoringstackUiIntent, - extraCheck: (plan) => - plan.slices.filter((s) => s.ui.home === true).length <= 1, + extraCheck: (plan) => boringstackAtMostOneHome(plan.slices.map((s) => s.ui)), }; /** * The SAME schema, TYPE-ERASED to `IPlanSchema` for the heterogeneous `IStackAdapter` * registry (a concrete `IPlanSchema` isn't assignable to `IPlanSchema` because * `extraCheck`'s parameter is contravariant). `validateUi` erases directly (a `v is IUiIntent` - * guard satisfies `v is unknown`); `extraCheck` re-narrows each slice's opaque `ui` with the same - * guard, so the runtime behaviour is identical. The typed `boringstackPlanSchema` stays for the + * guard satisfies `v is unknown`); both `extraCheck`s call the SAME `boringstackAtMostOneHome`, so + * there is no second hand-maintained body to drift. The typed `boringstackPlanSchema` stays for the * BoringStack build path, which needs `IProductPlan`. */ export const boringstackPlanSchemaErased: IPlanSchema = { system: PLANNER_SYSTEM, validateUi: isBoringstackUiIntent, - extraCheck: (plan) => - plan.slices.filter((s) => isBoringstackUiIntent(s.ui) && s.ui.home === true) - .length <= 1, + extraCheck: (plan) => boringstackAtMostOneHome(plan.slices.map((s) => s.ui)), }; /** diff --git a/packages/core/tests/plan-extension.test.ts b/packages/core/tests/plan-extension.test.ts index d74d4188..ac8745df 100644 --- a/packages/core/tests/plan-extension.test.ts +++ b/packages/core/tests/plan-extension.test.ts @@ -93,15 +93,34 @@ describe("boringstackPlanSchema.extraCheck (≤1 home)", () => { }); describe("boringstackPlanSchemaErased (type-erased parity)", () => { - test("its extraCheck matches the typed schema on the ≤1-home rule", () => { - // The erased schema re-narrows each opaque ui with isBoringstackUiIntent, so it agrees with - // the typed one — including that a non-UI slice (ui not a valid intent) contributes no home. - const nonUiSlice = { entity, ui: {}, verification }; + test("its extraCheck returns the SAME verdict as the typed schema on every valid plan", () => { + // Both schemas' extraCheck call the SAME boringstackAtMostOneHome helper, so they can never + // drift. Prove it: on each valid plan the typed and erased verdicts are identical. + const cases = [ + plan([homeSlice(false), homeSlice(false)]), // 0 homes → true + plan([homeSlice(true), homeSlice(false)]), // 1 home → true + plan([homeSlice(true), homeSlice(true)]), // 2 homes → false + ]; + + for (const p of cases) { + const typed = boringstackPlanSchema.extraCheck?.(p); + + expect(boringstackPlanSchemaErased.extraCheck?.(p)).toBe(typed); + } + }); + + test("a slice whose ui is not a valid intent contributes no home (the documented divergence case)", () => { + // The one input the typed extraCheck can't be TYPED to receive (invalid ui): { home: true } + // with no screens is NOT a valid IUiIntent. The shared helper re-narrows with + // isBoringstackUiIntent, so it counts as NO home — a plan pairing one real home with such an + // invalid-ui slice stays ≤1 home (true). This is where the two bodies used to be able to + // diverge; sharing the helper removes the risk. + const invalidHomeSlice = { entity, ui: { home: true }, verification }; expect( boringstackPlanSchemaErased.extraCheck?.({ product: "p", - slices: [homeSlice(true), nonUiSlice], + slices: [homeSlice(true), invalidHomeSlice], }) ).toBe(true); expect( diff --git a/packages/core/tests/propose-plan.test.ts b/packages/core/tests/propose-plan.test.ts index d6f47271..86af5f02 100644 --- a/packages/core/tests/propose-plan.test.ts +++ b/packages/core/tests/propose-plan.test.ts @@ -19,8 +19,10 @@ import type { IProductPlan, ISlice, IPlanConstraints, + IPlanSchema, } from "../src/loop/planning/plan-types"; import { isProductPlan } from "../src/loop/planning/plan-store"; +import { isRecord } from "../src/lib/guards"; import type { IProvider } from "../src/inference"; // The BoringStack plan schema specializes the generic planner/parser to the web UI intent — these @@ -394,6 +396,79 @@ test("BoringStack opt-in: stripping also applies on the temperature-0.7 retry pa expect(plan?.slices.map((s) => s.entity.id)).toEqual(["Bookmark"]); }); +// ── proposePlan re-applies the injected extraCheck to the STRIPPED plan ────────────────────────── +// The core soundness fix: stripReservedSlices can invalidate a cross-slice invariant that held on +// the full plan, so proposePlan must re-run schema.extraCheck on the stripped result. The boringstack +// ≤1-home rule can't exercise this (removal only DECREASES homes, so a ≤1-home plan stays ≤1-home). +// Use a removal-SENSITIVE rule via a custom schema — "at least one home slice must remain" — which is +// exactly the class of invariant the re-check protects. +interface IHomeUi { + readonly home: boolean; +} +const isHomeUi = (v: unknown): v is IHomeUi => + isRecord(v) && typeof v.home === "boolean"; +const homeSchema: IPlanSchema = { + system: "home schema", + validateUi: isHomeUi, + extraCheck: (plan) => plan.slices.some((s) => s.ui.home), +}; +const homeSlice = (id: string, home: boolean): ISlice => ({ + entity: { id, desc: "d", fields: [], relationships: [], rules: [] }, + ui: { home }, + verification: { + mustRemainTrue: [], + mustNotHappen: ["x"], + acceptanceCheck: "bun test", + }, +}); +const homePlannerOf = (slices: ISlice[]): IProvider => ({ + complete: async () => ({ + content: JSON.stringify({ product: "p", slices }), + toolCalls: [], + }), +}); +const RESERVED_HOME = { + reservedEntities: new Set(["reserved"]), + onStripped: () => undefined, +}; + +test("proposePlan REJECTS (null) when stripping invalidates the injected extraCheck", async () => { + // Pre-strip the plan has a home (on the reserved slice) so it parses; stripping the reserved + // slice removes the only home, so the re-check must fail the plan. Without the post-strip + // re-check this would wrongly return the surviving [Real] slice. + const plan = await proposePlan( + { + planner: homePlannerOf([ + homeSlice("Reserved", true), + homeSlice("Real", false), + ]), + }, + { description: "x" }, + homeSchema, + RESERVED_HOME + ); + + expect(plan).toBeNull(); +}); + +test("proposePlan ACCEPTS the stripped plan when the injected extraCheck still holds", async () => { + // A home survives stripping (Real is also home), so the re-check passes and the stripped plan + // is returned — proving the re-check rejects only genuine post-strip violations. + const plan = await proposePlan( + { + planner: homePlannerOf([ + homeSlice("Reserved", true), + homeSlice("Real", true), + ]), + }, + { description: "x" }, + homeSchema, + RESERVED_HOME + ); + + expect(plan?.slices.map((s) => s.entity.id)).toEqual(["Real"]); +}); + test("PLANNER_EXAMPLE proposes no reserved identity entity", () => { // The worked example must model good behaviour: no User/Auth/Session slice. const ids = PLANNER_EXAMPLE.slices.map((s) => s.entity.id.toLowerCase()); diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index 03b54328..f6b4c379 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -256,8 +256,8 @@ const countMatches = (pattern: string, file: string): number => { return parsed.length; }; -/** Count ANCHORED matches over an arbitrary source string (via a temp file), for decoys. */ -const countOn = async (source: string): Promise => { +/** Count matches of `pattern` over an arbitrary source string (via a temp file), for decoys. */ +const countOnPat = async (pattern: string, source: string): Promise => { const dir = await mkdtemp(join(tmpdir(), "tsforge-guard-")); try { @@ -265,12 +265,16 @@ const countOn = async (source: string): Promise => { await writeFile(file, source); - return countMatches(ANCHORED, file); + return countMatches(pattern, file); } finally { await rm(dir, { recursive: true, force: true }); } }; +/** Count ANCHORED matches over an arbitrary source string (via a temp file), for decoys. */ +const countOn = (source: string): Promise => + countOnPat(ANCHORED, source); + describe("the REPL line handler wires greenfieldOrSend (ast-grep structural guard)", () => { test("the real handler has exactly one greenfieldOrSend call as its arrow's LAST statement", () => { expect(countMatches(ANCHORED, REPL_TS)).toBe(1); @@ -370,3 +374,32 @@ describe("the REPL line handler wires greenfieldOrSend (ast-grep structural guar ).toBe(1); }); }); + +// The multi-adapter planning claim: runGreenfieldPlanning must plan through the RESOLVED adapter's +// schema (`schema: stack.planSchema`), not a hardcoded boringstack schema — else the whole +// IStackAdapter.planSchema seam is decorative and a second adapter would silently be planned by +// boringstack's schema. runGreenfieldPlanning is module-private (its runPlanning call runs real +// capability resolution, so it is not unit-reachable), so this is a source-guard: the runPlanning +// call inside repl.ts must carry `schema: stack.planSchema`. A hardcoded schema drops it to 0. +const SCHEMA_WIRING = + "runPlanning($D, { $$$A schema: stack.planSchema, $$$B })"; + +describe("runGreenfieldPlanning plans through the RESOLVED adapter's schema (ast-grep guard)", () => { + test("the real runPlanning call passes schema: stack.planSchema (the resolved adapter's)", () => { + expect(countMatches(SCHEMA_WIRING, REPL_TS)).toBe(1); + }); + + test("SANITY: the resolved-schema call shape matches (so the negative fails for the right reason)", async () => { + const ok = + "runPlanning(dir, { planner: p, schema: stack.planSchema, constraints: c });"; + + expect(await countOnPat(SCHEMA_WIRING, ok)).toBe(1); + }); + + test("rejects a runPlanning call that hardcodes a concrete schema (the seam-bypass regression)", async () => { + const hardcoded = + "runPlanning(dir, { planner: p, schema: boringstackPlanSchema, constraints: c });"; + + expect(await countOnPat(SCHEMA_WIRING, hardcoded)).toBe(0); + }); +}); From 8528d143ad2bac85ced1a40c7bb21f781ef4f9f4 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 05:11:57 +0200 Subject: [PATCH 40/53] test(core): close WS3 plan-seam panel r2 minors (no-hidden-rule + arg-identity + stale docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generic-plan-seam: two home-marked game slices are ACCEPTED, proving core applies NO residual ≤1-home rule of its own — every cross-slice rule comes from the injected schema - repl-greenfield-stack: assert resolveGreenfieldStack consults hasApprovedPlan with the DIR and the EXACT resolved adapter (its schema drives the check), not just a zero-arg stub - plan-types / run-planning: drop the stale 'example' from the IPlanSchema and IPlanningDeps.schema doc comments (the field was removed; system embeds it) --- packages/core/src/loop/planning/plan-types.ts | 7 ++--- .../core/src/loop/planning/run-planning.ts | 5 ++-- packages/core/tests/generic-plan-seam.test.ts | 21 +++++++++++++++ .../core/tests/repl-greenfield-stack.test.ts | 26 +++++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/core/src/loop/planning/plan-types.ts b/packages/core/src/loop/planning/plan-types.ts index 1ea07b67..a80e2f10 100644 --- a/packages/core/src/loop/planning/plan-types.ts +++ b/packages/core/src/loop/planning/plan-types.ts @@ -36,9 +36,10 @@ export interface IProductPlan { /** * The STACK-SPECIFIC plan schema the generic planner + parser depend on, injected by the adapter - * (BoringStack today). Core's `proposePlan` teaches `system` to the model, uses `example` to pin - * the exact output shape, validates each slice's `ui` with `validateUi` at the parse boundary, and - * applies the optional cross-slice `extraCheck`. This is what keeps the WEB plan shape (screens, + * (BoringStack today). Core's `proposePlan` teaches `system` to the model (the adapter embeds any + * worked example INTO that string — there is no separate `example` field), validates each slice's + * `ui` with `validateUi` at the parse boundary, and applies the optional cross-slice `extraCheck`. + * This is what keeps the WEB plan shape (screens, * nav, layout, home) OUT of core — a Phaser adapter would supply its own schema, or a UI-less one * a trivial pass-through. */ diff --git a/packages/core/src/loop/planning/run-planning.ts b/packages/core/src/loop/planning/run-planning.ts index f6206576..6f591c92 100644 --- a/packages/core/src/loop/planning/run-planning.ts +++ b/packages/core/src/loop/planning/run-planning.ts @@ -5,8 +5,9 @@ import type { IProvider } from "../../inference"; export interface IPlanningDeps { planner: IProvider; - /** The stack's plan schema (prompt + example + UI validator + cross-slice rule), injected by the - * caller from the resolved stack adapter — this is what keeps the web plan shape out of core. */ + /** The stack's plan schema (system prompt + UI validator + optional cross-slice rule), injected + * by the caller from the resolved stack adapter — this is what keeps the web plan shape out of + * core. */ schema: IPlanSchema; /** OPT-IN stack-specific planning constraints (guidance + reserved entities). * Omitted → the planner is stack-agnostic. The BoringStack path supplies the diff --git a/packages/core/tests/generic-plan-seam.test.ts b/packages/core/tests/generic-plan-seam.test.ts index 3e4b277b..9e9ac991 100644 --- a/packages/core/tests/generic-plan-seam.test.ts +++ b/packages/core/tests/generic-plan-seam.test.ts @@ -35,6 +35,14 @@ const gameSlice = (scene: string): unknown => ({ ui: { scene }, verification, }); +// A game slice that ALSO carries a `home: true` property. The game validator ignores `home` +// (it only checks `scene`), so this is still a valid game ui — used to prove core does NOT +// secretly apply BoringStack's ≤1-home rule to the extra property. +const gameSliceHome = (scene: string): unknown => ({ + entity, + ui: { scene, home: true }, + verification, +}); const webSlice = (): unknown => ({ entity, ui: { screens: ["list"], action: "a", shows: [], nav: "N" }, // valid WEB ui, invalid GAME ui @@ -70,4 +78,17 @@ describe("core plan validation defers UI + cross-slice rules to the injected sch expect(isProductPlan(bad, isGameUi)).toBe(false); }); + + test("does NOT secretly apply BoringStack's ≤1-home rule (TWO home-marked game slices accepted)", () => { + // Both slices carry `home: true`. If core still enforced a hardcoded ≤1-home rule ALONGSIDE + // the injected schema, this two-home plan would be rejected. With the game validator (which + // ignores `home`) and NO injected extraCheck, both are accepted — proving core carries no + // residual web cross-slice rule; every cross-slice rule comes from the injected schema. + const twoHomes = { + product: "g", + slices: [gameSliceHome("a"), gameSliceHome("b")], + }; + + expect(isProductPlan(twoHomes, isGameUi)).toBe(true); + }); }); diff --git a/packages/core/tests/repl-greenfield-stack.test.ts b/packages/core/tests/repl-greenfield-stack.test.ts index f6b4c379..689992a4 100644 --- a/packages/core/tests/repl-greenfield-stack.test.ts +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -40,6 +40,32 @@ describe("resolveGreenfieldStack (the REPL interception decision)", () => { expect(resolved).toBe(detected); }); + test("consults hasApprovedPlan with the DIR and the EXACT resolved adapter (its schema drives the check)", async () => { + // The approved-plan check must run against the RESOLVED adapter's plan schema, not a fixed one + // — so resolveGreenfieldStack must hand hasApprovedPlan (dir, detectedAdapter). Capture the + // args and assert identity; a regression that drops the stack arg or passes the wrong adapter + // (so a second stack's plans would be validated by boringstack's schema) fails here. + const detected = stub("boringstack", true); + // Capture into an array (not a reassigned local) so the compiler doesn't flow-narrow the + // closure-mutated value to `never` at the assertion site. + const calls: { dir: string; stack: IStackAdapter }[] = []; + + const resolved = await resolveGreenfieldStack( + "/dir", + [stub("other", false), detected], + (dir, stack) => { + calls.push({ dir, stack }); + + return Promise.resolve(false); + } + ); + + expect(resolved).toBe(detected); + expect(calls).toHaveLength(1); + expect(calls[0]?.dir).toBe("/dir"); + expect(calls[0]?.stack).toBe(detected); + }); + test("a detected project that is ALREADY planned → null (bypass, no re-planning)", async () => { const resolved = await resolveGreenfieldStack( "/dir", From 1f5a0a69b02480d8af88c4b76ac80f85a2b689a0 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 05:12:09 +0200 Subject: [PATCH 41/53] =?UTF-8?q?feat(core):=20make=20the=20core=E2=86=94a?= =?UTF-8?q?dapter=20boundary=20MECHANICAL=20(WS4=20=E2=80=94=20seam=20recl?= =?UTF-8?q?amation=20finish=20line)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic core loop (loop/** minus loop/boringstack/**) must never import the BoringStack adapter. WS1-WS3 reclaimed the leaks (conventions, stack-adapter, plan spine) by hand; this rule keeps them reclaimed — a future core-loop file that reaches into loop/boringstack/** now fails 'bun run validate', not review. - eslint.config.js: a @typescript-eslint/no-restricted-imports rule (the ts-eslint superset, so it also catches 'import type') scoped to loop/**/*.ts with loop/boringstack/** ignored. The rule's SCOPE is the definition of 'core loop': the composition roots that legitimately wire the adapter (cli.ts, cli/**), scripts, and tests all live OUTSIDE loop/** and are exempt with no allow-list; the adapter's own intra-boringstack imports are excluded via 'ignores'. - core-adapter-boundary.test.ts: proves the rule is LIVE — lints throwaway fixtures on the CORE side of the boundary and asserts it FIRES on a value import AND a type-only import of the adapter, and stays SILENT on a core import (a real boundary, not a blanket ban). --- eslint.config.js | 38 +++++++++ .../core/tests/core-adapter-boundary.test.ts | 78 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 packages/core/tests/core-adapter-boundary.test.ts diff --git a/eslint.config.js b/eslint.config.js index b879d985..a3e2eddb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -219,5 +219,43 @@ export default tseslint.config( "@typescript-eslint/naming-convention": "off", "@typescript-eslint/no-unnecessary-condition": "off", }, + }, + { + /* + * MECHANICAL core↔adapter boundary (the law made enforceable, WS4). The generic core loop + * — everything under `loop/**` EXCEPT the BoringStack adapter itself — must never import the + * adapter (`loop/boringstack/**`). WS1–WS3 reclaimed the leaks (conventions, stack-adapter, + * plan spine) by hand; this rule keeps them reclaimed: any future core-loop file that reaches + * back into `loop/boringstack/**` fails `bun run validate`, not code review. + * + * The rule's SCOPE is the definition of "core loop": this config block applies to every + * `.ts` under `loop/` except the `loop/boringstack/` subtree, so the exemptions fall out of the tree with no + * hand-maintained allow-list — the composition roots that legitimately wire the adapter in + * (`cli.ts`, `cli/**`), the scripts, and the tests all live OUTSIDE `loop/**` and are never + * subject to it; the adapter's own intra-`boringstack` imports are excluded via `ignores`. + * `no-restricted-paths` resolves PHYSICAL paths, so it catches every relative form + * (`../boringstack/x`, `./boringstack/x`, …), which a specifier glob cannot. + */ + files: ["packages/core/src/loop/**/*.ts"], + ignores: ["packages/core/src/loop/boringstack/**"], + rules: { + // `@typescript-eslint/no-restricted-imports` (a superset of core no-restricted-imports that + // ALSO catches `import type`) matches the import SPECIFIER — no path resolver needed. From + // within `loop/`, the only way to reach the adapter is a relative specifier that names the + // `boringstack/` path segment (`../boringstack/x`, `./boringstack/x`, `../../boringstack/x`), + // so the two globs below (the bare dir index + anything under it) catch every form. + "@typescript-eslint/no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["**/boringstack", "**/boringstack/**"], + message: + "Core loop must not import the BoringStack adapter (loop/boringstack/**). Core stays stack-agnostic — inject the adapter behind its seam (IConventionProvider / IStackAdapter / IPlanSchema) and wire it at a composition root (cli.ts, cli/**, scripts/**).", + }, + ], + }, + ], + }, } ); diff --git a/packages/core/tests/core-adapter-boundary.test.ts b/packages/core/tests/core-adapter-boundary.test.ts new file mode 100644 index 00000000..a996b622 --- /dev/null +++ b/packages/core/tests/core-adapter-boundary.test.ts @@ -0,0 +1,78 @@ +import { test, expect } from "bun:test"; +import { join } from "node:path"; +import { writeFileSync, rmSync, mkdirSync } from "node:fs"; + +// WS4 — the core↔adapter law made MECHANICAL. eslint.config.js forbids the generic core loop +// (`loop/**` except `loop/boringstack/**`) from importing the BoringStack adapter. WS1–WS3 +// reclaimed the leaks by hand; this test proves the rule that keeps them reclaimed is LIVE — a +// leak fails `bun run validate`, not just review. It lints throwaway fixtures placed on the CORE +// side of the boundary (under `loop/`, NOT under `loop/boringstack/`) and asserts the rule fires +// on an adapter import and stays silent on a core import. One eslint spawn (one TS-program load) +// over all three fixtures; the fixture dir is always removed. +const ROOT = join(import.meta.dir, "..", "..", ".."); +const ESLINT = join(ROOT, "node_modules", ".bin", "eslint"); +const FIXTURE_DIR = join( + ROOT, + "packages", + "core", + "src", + "loop", + "__adapter_boundary_fixture__" +); +const RULE = "@typescript-eslint/no-restricted-imports"; + +interface IFileResult { + readonly filePath: string; + readonly messages: readonly { readonly ruleId: string | null }[]; +} + +/** Write the given `{ name: source }` fixtures under FIXTURE_DIR, lint the dir ONCE, and return a + * map of basename → the ruleIds eslint reported for that file. Always cleans up. */ +const lintFixtures = ( + files: Record +): Map => { + mkdirSync(FIXTURE_DIR, { recursive: true }); + + for (const [name, source] of Object.entries(files)) { + writeFileSync(join(FIXTURE_DIR, name), source); + } + + try { + const proc = Bun.spawnSync([ESLINT, "--format", "json", FIXTURE_DIR], { + cwd: ROOT, + }); + const parsed: IFileResult[] = JSON.parse(proc.stdout.toString()); + const byName = new Map(); + + for (const r of parsed) { + byName.set( + r.filePath.split("/").pop() ?? r.filePath, + r.messages.map((m) => m.ruleId) + ); + } + + return byName; + } finally { + rmSync(FIXTURE_DIR, { recursive: true, force: true }); + } +}; + +test("the mechanical core↔adapter boundary rejects a core-loop import of loop/boringstack (value + type) and allows a core import", () => { + const results = lintFixtures({ + "leak-value.ts": + 'import { boringstackPlanSchema } from "../boringstack/plan-extension";\n\nexport const a = boringstackPlanSchema;\n', + "leak-type.ts": + 'import type { IUiIntent } from "../boringstack/plan-extension";\n\nexport type A = IUiIntent;\n', + "core-ok.ts": + 'import { isProductPlan } from "../planning/plan-store";\n\nexport const b = isProductPlan;\n', + }); + + // A value import of the adapter fires the boundary rule. + expect(results.get("leak-value.ts")).toContain(RULE); + // A TYPE-ONLY import of the adapter fires it too — the @typescript-eslint superset of + // no-restricted-imports catches `import type`, which core no-restricted-imports would miss. + expect(results.get("leak-type.ts")).toContain(RULE); + // Importing another CORE module is allowed — the rule targets only the adapter subtree, so it + // is a real boundary, not a blanket ban that would also block legitimate intra-core imports. + expect(results.get("core-ok.ts") ?? []).not.toContain(RULE); +}, 30000); From 51a9552baff2ddf047305535533b6cad7c55278a Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 05:22:33 +0200 Subject: [PATCH 42/53] test(core): harden the WS4 boundary test + fix stale eslint comment (panel minors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eslint.config.js: correct the leftover 'no-restricted-paths resolves PHYSICAL paths' note (from an abandoned draft) — the rule is @typescript-eslint/no-restricted-imports matching the SPECIFIER; every adapter reach from inside loop/ names the boringstack/ segment - core-adapter-boundary.test: suffix the fixture dir with process.pid so concurrent test workers can't race on it; surface a non-JSON eslint stdout as a clear error (exit code + stderr) instead of a cryptic JSON.parse throw --- eslint.config.js | 8 ++++++-- .../core/tests/core-adapter-boundary.test.ts | 19 +++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index a3e2eddb..7236f631 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -233,8 +233,12 @@ export default tseslint.config( * hand-maintained allow-list — the composition roots that legitimately wire the adapter in * (`cli.ts`, `cli/**`), the scripts, and the tests all live OUTSIDE `loop/**` and are never * subject to it; the adapter's own intra-`boringstack` imports are excluded via `ignores`. - * `no-restricted-paths` resolves PHYSICAL paths, so it catches every relative form - * (`../boringstack/x`, `./boringstack/x`, …), which a specifier glob cannot. + * Enforced with `@typescript-eslint/no-restricted-imports` matching the SPECIFIER (see the + * inline note on the rule below) — every way to reach the adapter from inside `loop/` is a + * relative specifier that names the `boringstack/` segment (`../boringstack/x`, + * `./boringstack/x`, `../../boringstack/x`), so a specifier glob catches them all with no + * path resolver (the physical-path rule `import-x/no-restricted-paths` would need a TS + * resolver that isn't installed). */ files: ["packages/core/src/loop/**/*.ts"], ignores: ["packages/core/src/loop/boringstack/**"], diff --git a/packages/core/tests/core-adapter-boundary.test.ts b/packages/core/tests/core-adapter-boundary.test.ts index a996b622..e779bc81 100644 --- a/packages/core/tests/core-adapter-boundary.test.ts +++ b/packages/core/tests/core-adapter-boundary.test.ts @@ -11,13 +11,15 @@ import { writeFileSync, rmSync, mkdirSync } from "node:fs"; // over all three fixtures; the fixture dir is always removed. const ROOT = join(import.meta.dir, "..", "..", ".."); const ESLINT = join(ROOT, "node_modules", ".bin", "eslint"); +// Suffix the fixture dir with the worker PID so concurrent bun-test processes never share (and +// race on write/lint/rm of) the same directory. const FIXTURE_DIR = join( ROOT, "packages", "core", "src", "loop", - "__adapter_boundary_fixture__" + `__adapter_boundary_fixture_${process.pid}__` ); const RULE = "@typescript-eslint/no-restricted-imports"; @@ -41,7 +43,20 @@ const lintFixtures = ( const proc = Bun.spawnSync([ESLINT, "--format", "json", FIXTURE_DIR], { cwd: ROOT, }); - const parsed: IFileResult[] = JSON.parse(proc.stdout.toString()); + const stdout = proc.stdout.toString(); + // eslint EXITS NON-ZERO here (the fixtures have lint errors — that's the point), but still + // writes its JSON report to stdout. A crash (missing binary, config load failure) instead + // yields empty/non-JSON stdout; surface THAT as a clear failure, not a cryptic JSON.parse throw. + let parsed: IFileResult[]; + + try { + parsed = JSON.parse(stdout); + } catch { + throw new Error( + `eslint did not emit a JSON report (exit ${proc.exitCode}). stderr: ${proc.stderr.toString().slice(0, 800)} | stdout: ${stdout.slice(0, 200)}` + ); + } + const byName = new Map(); for (const r of parsed) { From 310ead47387f2656a717dab60193654bf2d0375a Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 05:33:02 +0200 Subject: [PATCH 43/53] test(core): make the WS4 negative control prove it was linted + full crash output (panel minors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assert the core-ok control file HAS a result entry before checking the rule is absent, so an omitted/ignored/mis-scoped file fails instead of green-washing the allow-case via a '?? []' default - surface the FULL eslint stderr+stdout on the non-JSON crash path (no more slice() truncation — no-silent-truncation) --- packages/core/tests/core-adapter-boundary.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/tests/core-adapter-boundary.test.ts b/packages/core/tests/core-adapter-boundary.test.ts index e779bc81..ee2d17ff 100644 --- a/packages/core/tests/core-adapter-boundary.test.ts +++ b/packages/core/tests/core-adapter-boundary.test.ts @@ -53,7 +53,7 @@ const lintFixtures = ( parsed = JSON.parse(stdout); } catch { throw new Error( - `eslint did not emit a JSON report (exit ${proc.exitCode}). stderr: ${proc.stderr.toString().slice(0, 800)} | stdout: ${stdout.slice(0, 200)}` + `eslint did not emit a JSON report (exit ${proc.exitCode}). stderr: ${proc.stderr.toString()} | stdout: ${stdout}` ); } @@ -89,5 +89,10 @@ test("the mechanical core↔adapter boundary rejects a core-loop import of loop/ expect(results.get("leak-type.ts")).toContain(RULE); // Importing another CORE module is allowed — the rule targets only the adapter subtree, so it // is a real boundary, not a blanket ban that would also block legitimate intra-core imports. - expect(results.get("core-ok.ts") ?? []).not.toContain(RULE); + // Assert the control file was ACTUALLY linted first (a missing entry — ignored / mis-scoped / + // basename mismatch — must fail, not green-wash the negative case via a `?? []` default). + const coreOk = results.get("core-ok.ts"); + + expect(coreOk).toBeDefined(); + expect(coreOk).not.toContain(RULE); }, 30000); From 1f92d1805789cbdecbd881c0c6e424ac9a8a6f1b Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 05:45:48 +0200 Subject: [PATCH 44/53] =?UTF-8?q?feat(core):=20close=20the=20dynamic-impor?= =?UTF-8?q?t=20escape=20hatch=20in=20the=20core=E2=86=94adapter=20boundary?= =?UTF-8?q?=20(panel=20r3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no-restricted-imports covers static import/export declarations but NOT a dynamic import("../boringstack/x") — a core file could reach the adapter through it and bypass the mechanical boundary. Add a no-restricted-syntax ImportExpression selector (scoped to the same loop block, same exemptions) matching a boringstack path segment. Re-includes the enum-ban selector since a same-key rule in a later flat-config block replaces it wholesale for loop files. Regression test gains a leak-dynamic.ts fixture asserting the dynamic form is rejected (under the no-restricted-syntax ruleId). Verified: dynamic + static + type leaks all fire, the enum ban still holds in loop/, and the real tree is clean. --- eslint.config.js | 18 ++++++++++++++++++ .../core/tests/core-adapter-boundary.test.ts | 10 +++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index 7236f631..5922e1a0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -260,6 +260,24 @@ export default tseslint.config( ], }, ], + // no-restricted-imports covers STATIC import/export declarations but NOT a dynamic + // `import("../boringstack/x")` (an ImportExpression) — which would otherwise reach the adapter + // and bypass the boundary. Close that with an AST selector on the dynamic-import argument. + // NOTE: this REPLACES the base `no-restricted-syntax` (the enum ban) for loop files — flat + // config overrides the whole rule per key — so the enum selector is re-included here. + "no-restricted-syntax": [ + "error", + { + selector: "TSEnumDeclaration", + message: "Use 'as const' object literals instead of enums.", + }, + { + selector: + "ImportExpression > Literal[value=/(^|\\u002F)boringstack($|\\u002F)/]", + message: + "Core loop must not import the BoringStack adapter (loop/boringstack/**), including via dynamic import(). Inject the adapter behind its seam and wire it at a composition root (cli.ts, cli/**, scripts/**).", + }, + ], }, } ); diff --git a/packages/core/tests/core-adapter-boundary.test.ts b/packages/core/tests/core-adapter-boundary.test.ts index ee2d17ff..a5f863ea 100644 --- a/packages/core/tests/core-adapter-boundary.test.ts +++ b/packages/core/tests/core-adapter-boundary.test.ts @@ -22,6 +22,9 @@ const FIXTURE_DIR = join( `__adapter_boundary_fixture_${process.pid}__` ); const RULE = "@typescript-eslint/no-restricted-imports"; +// The dynamic-import escape hatch (`import("../boringstack/x")`) is caught by a no-restricted-syntax +// AST selector, not no-restricted-imports, so it reports under a different ruleId. +const SYNTAX_RULE = "no-restricted-syntax"; interface IFileResult { readonly filePath: string; @@ -72,12 +75,14 @@ const lintFixtures = ( } }; -test("the mechanical core↔adapter boundary rejects a core-loop import of loop/boringstack (value + type) and allows a core import", () => { +test("the mechanical core↔adapter boundary rejects a core-loop import of loop/boringstack (value + type + dynamic) and allows a core import", () => { const results = lintFixtures({ "leak-value.ts": 'import { boringstackPlanSchema } from "../boringstack/plan-extension";\n\nexport const a = boringstackPlanSchema;\n', "leak-type.ts": 'import type { IUiIntent } from "../boringstack/plan-extension";\n\nexport type A = IUiIntent;\n', + "leak-dynamic.ts": + 'export async function load() {\n return import("../boringstack/plan-extension");\n}\n', "core-ok.ts": 'import { isProductPlan } from "../planning/plan-store";\n\nexport const b = isProductPlan;\n', }); @@ -87,6 +92,9 @@ test("the mechanical core↔adapter boundary rejects a core-loop import of loop/ // A TYPE-ONLY import of the adapter fires it too — the @typescript-eslint superset of // no-restricted-imports catches `import type`, which core no-restricted-imports would miss. expect(results.get("leak-type.ts")).toContain(RULE); + // A DYNAMIC import() of the adapter is caught too — by the no-restricted-syntax ImportExpression + // selector (no-restricted-imports doesn't see dynamic imports), so the boundary has no escape hatch. + expect(results.get("leak-dynamic.ts")).toContain(SYNTAX_RULE); // Importing another CORE module is allowed — the rule targets only the adapter subtree, so it // is a real boundary, not a blanket ban that would also block legitimate intra-core imports. // Assert the control file was ACTUALLY linted first (a missing entry — ignored / mis-scoped / From 644da0f255c8590898c2f088269e208516d8ffae Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 05:57:23 +0200 Subject: [PATCH 45/53] feat(core): close all statically-analyzable dynamic-import evasions + guard enum ban (panel r4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r4 majors: - ImportExpression selector matched only string Literals → templated/concat/ternary dynamic imports evaded. Broaden to a DESCENDANT Literal selector (catches a boringstack literal anywhere in the arg: , ternary) plus a TemplateElement selector (catches import(`../boringstack/x`)). Verified: literal/template/concat/ternary all fire; a fully-computed import(var) and a segment-splitting concat are the documented inherent limit (un-lintable for ANY rule, and not how a known module is imported) — spelled out in the config comment. - no test guarded that the re-included enum ban still fires in loop files → add an enum.ts fixture asserting no-restricted-syntax fires (a future edit dropping the TSEnumDeclaration selector can no longer silently relax the loop gate). - move mkdir/writeFile INSIDE the try so cleanup runs even if they throw. Regression test now covers value + type + dynamic-literal + dynamic-template + enum, and asserts the core-ok control is neither restricted nor enum-flagged. --- eslint.config.js | 21 ++++++++++++--- .../core/tests/core-adapter-boundary.test.ts | 26 ++++++++++++++----- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 5922e1a0..c7427368 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -262,9 +262,16 @@ export default tseslint.config( ], // no-restricted-imports covers STATIC import/export declarations but NOT a dynamic // `import("../boringstack/x")` (an ImportExpression) — which would otherwise reach the adapter - // and bypass the boundary. Close that with an AST selector on the dynamic-import argument. - // NOTE: this REPLACES the base `no-restricted-syntax` (the enum ban) for loop files — flat - // config overrides the whole rule per key — so the enum selector is re-included here. + // and bypass the boundary. Close every statically-analyzable dynamic form with AST selectors on + // the import() argument: any string Literal naming a boringstack segment ANYWHERE in the + // argument (covers `import("../boringstack/x")`, a ternary, and `import("../boringstack/" + n)`), + // and any template-literal quasi naming it (covers `import(`../boringstack/x`)`). INHERENT + // LIMIT (documented, not a gap to chase): a FULLY-COMPUTED path — `import(runtimeVar)` or a + // segment-splitting concat `import("../bor" + "ingstack/x")` where no single node contains the + // segment — is beyond static AST matching for ANY lint rule; it is also not how a known module + // is ever imported. NOTE: this whole rule REPLACES the base `no-restricted-syntax` (the enum + // ban) for loop files — flat config overrides a rule wholesale per key — so the enum selector + // is re-included here (and a test asserts it still fires in loop files). "no-restricted-syntax": [ "error", { @@ -273,10 +280,16 @@ export default tseslint.config( }, { selector: - "ImportExpression > Literal[value=/(^|\\u002F)boringstack($|\\u002F)/]", + "ImportExpression Literal[value=/(^|\\u002F)boringstack($|\\u002F)/]", message: "Core loop must not import the BoringStack adapter (loop/boringstack/**), including via dynamic import(). Inject the adapter behind its seam and wire it at a composition root (cli.ts, cli/**, scripts/**).", }, + { + selector: + "ImportExpression TemplateElement[value.cooked=/(^|\\u002F)boringstack($|\\u002F)/]", + message: + "Core loop must not import the BoringStack adapter (loop/boringstack/**), including via a templated dynamic import(). Inject the adapter behind its seam and wire it at a composition root (cli.ts, cli/**, scripts/**).", + }, ], }, } diff --git a/packages/core/tests/core-adapter-boundary.test.ts b/packages/core/tests/core-adapter-boundary.test.ts index a5f863ea..a901f197 100644 --- a/packages/core/tests/core-adapter-boundary.test.ts +++ b/packages/core/tests/core-adapter-boundary.test.ts @@ -7,8 +7,8 @@ import { writeFileSync, rmSync, mkdirSync } from "node:fs"; // reclaimed the leaks by hand; this test proves the rule that keeps them reclaimed is LIVE — a // leak fails `bun run validate`, not just review. It lints throwaway fixtures placed on the CORE // side of the boundary (under `loop/`, NOT under `loop/boringstack/`) and asserts the rule fires -// on an adapter import and stays silent on a core import. One eslint spawn (one TS-program load) -// over all three fixtures; the fixture dir is always removed. +// on static / type-only / dynamic (literal + templated) adapter imports and stays silent on a core +// import. One eslint spawn (one TS-program load) over all fixtures; the fixture dir is always removed. const ROOT = join(import.meta.dir, "..", "..", ".."); const ESLINT = join(ROOT, "node_modules", ".bin", "eslint"); // Suffix the fixture dir with the worker PID so concurrent bun-test processes never share (and @@ -36,13 +36,14 @@ interface IFileResult { const lintFixtures = ( files: Record ): Map => { - mkdirSync(FIXTURE_DIR, { recursive: true }); + try { + // Create + write INSIDE the try so the finally cleanup runs even if mkdir/write throws. + mkdirSync(FIXTURE_DIR, { recursive: true }); - for (const [name, source] of Object.entries(files)) { - writeFileSync(join(FIXTURE_DIR, name), source); - } + for (const [name, source] of Object.entries(files)) { + writeFileSync(join(FIXTURE_DIR, name), source); + } - try { const proc = Bun.spawnSync([ESLINT, "--format", "json", FIXTURE_DIR], { cwd: ROOT, }); @@ -83,6 +84,9 @@ test("the mechanical core↔adapter boundary rejects a core-loop import of loop/ 'import type { IUiIntent } from "../boringstack/plan-extension";\n\nexport type A = IUiIntent;\n', "leak-dynamic.ts": 'export async function load() {\n return import("../boringstack/plan-extension");\n}\n', + "leak-dynamic-template.ts": + "export async function load() {\n return import(`../boringstack/plan-extension`);\n}\n", + "enum.ts": "export enum Color {\n Red,\n Blue,\n}\n", "core-ok.ts": 'import { isProductPlan } from "../planning/plan-store";\n\nexport const b = isProductPlan;\n', }); @@ -95,6 +99,13 @@ test("the mechanical core↔adapter boundary rejects a core-loop import of loop/ // A DYNAMIC import() of the adapter is caught too — by the no-restricted-syntax ImportExpression // selector (no-restricted-imports doesn't see dynamic imports), so the boundary has no escape hatch. expect(results.get("leak-dynamic.ts")).toContain(SYNTAX_RULE); + // A TEMPLATED dynamic import (`import(`../boringstack/x`)`) is caught by the TemplateElement + // selector — the string-Literal-only form a prior round could evade is closed. + expect(results.get("leak-dynamic-template.ts")).toContain(SYNTAX_RULE); + // The boundary block REPLACES the base no-restricted-syntax for loop files, re-including the + // enum ban. Prove that ban still fires here, so a future edit dropping the TSEnumDeclaration + // selector can't silently relax the gate for the whole core loop. + expect(results.get("enum.ts")).toContain(SYNTAX_RULE); // Importing another CORE module is allowed — the rule targets only the adapter subtree, so it // is a real boundary, not a blanket ban that would also block legitimate intra-core imports. // Assert the control file was ACTUALLY linted first (a missing entry — ignored / mis-scoped / @@ -103,4 +114,5 @@ test("the mechanical core↔adapter boundary rejects a core-loop import of loop/ expect(coreOk).toBeDefined(); expect(coreOk).not.toContain(RULE); + expect(coreOk).not.toContain(SYNTAX_RULE); }, 30000); From 23d03b35a61eb69cd19ff57b2de9238f34bb2f70 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 06:11:46 +0200 Subject: [PATCH 46/53] feat(core): close the immediately-invoked createRequire loader path + document runtime-loader limit (panel r5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r5 was PASS (4/4), with one agreement-1 advisory: a core file could reach the adapter via createRequire(import.meta.url)("../boringstack/x") — no-restricted-imports only sees the permitted node:module import, and the dynamic-import selectors don't match a require() call. - add a no-restricted-syntax selector for the immediately-invoked form CallExpression[callee.callee.name=createRequire] > Literal[/boringstack/] — the one statically-matchable createRequire shape. - reframe the config comment: the boundary closes every form whose target path is a static STRING in the AST (static import, import type, dynamic import literal/ template/concat/ternary, immediately-invoked createRequire). The DOCUMENTED INHERENT LIMIT (un-lintable for any rule, and adversarial — this is pure-ESM with zero createRequire use): a runtime-assembled path — import(var), a segment-split concat, or a require fn ALIASED to a variable first. - regression test gains a leak-require.ts fixture asserting the immediately-invoked createRequire form is rejected. Verified: static + type + dynamic(literal/template/concat/ternary) + immediately- invoked createRequire all fire; aliased-require and computed-var forms correctly do not (documented limit); a core-module load via createRequire does not fire; real tree clean. --- eslint.config.js | 35 ++++++++++++------- .../core/tests/core-adapter-boundary.test.ts | 6 ++++ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index c7427368..1549c7c3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -260,18 +260,23 @@ export default tseslint.config( ], }, ], - // no-restricted-imports covers STATIC import/export declarations but NOT a dynamic - // `import("../boringstack/x")` (an ImportExpression) — which would otherwise reach the adapter - // and bypass the boundary. Close every statically-analyzable dynamic form with AST selectors on - // the import() argument: any string Literal naming a boringstack segment ANYWHERE in the - // argument (covers `import("../boringstack/x")`, a ternary, and `import("../boringstack/" + n)`), - // and any template-literal quasi naming it (covers `import(`../boringstack/x`)`). INHERENT - // LIMIT (documented, not a gap to chase): a FULLY-COMPUTED path — `import(runtimeVar)` or a - // segment-splitting concat `import("../bor" + "ingstack/x")` where no single node contains the - // segment — is beyond static AST matching for ANY lint rule; it is also not how a known module - // is ever imported. NOTE: this whole rule REPLACES the base `no-restricted-syntax` (the enum - // ban) for loop files — flat config overrides a rule wholesale per key — so the enum selector - // is re-included here (and a test asserts it still fires in loop files). + // no-restricted-imports covers STATIC import/export declarations but NOT runtime module loads + // — a dynamic `import("../boringstack/x")` (ImportExpression) or a `createRequire(...)(...)` + // (CommonJS interop) — which would otherwise reach the adapter and bypass the boundary. Close + // every form whose target path is a STATIC STRING somewhere in the AST, via selectors on the + // loader argument: (1) any string Literal naming a boringstack segment ANYWHERE in an + // import() argument (covers `import("../boringstack/x")`, a ternary, and + // `import("../boringstack/" + n)`); (2) any template-literal quasi naming it (covers + // `import(`../boringstack/x`)`); (3) an immediately-invoked `createRequire(...)("...boringstack...")`. + // DOCUMENTED INHERENT LIMIT (not a gap to chase — the WS2 lesson: close what's statically + // matchable, document the rest): a path assembled at RUNTIME is beyond static AST matching for + // ANY lint rule — `import(runtimeVar)`, a segment-splitting concat `"../bor" + "ingstack/x"` + // where no single node holds the segment, or a require function ALIASED to a variable first + // (`const r = createRequire(u); r("../boringstack/x")`). None of these is how a known module is + // ever loaded; they are adversarial, and this is a pure-ESM codebase with zero `createRequire` + // use. NOTE: this whole rule REPLACES the base `no-restricted-syntax` (the enum ban) for loop + // files — flat config overrides a rule wholesale per key — so the enum selector is re-included + // here (and a test asserts it still fires in loop files). "no-restricted-syntax": [ "error", { @@ -290,6 +295,12 @@ export default tseslint.config( message: "Core loop must not import the BoringStack adapter (loop/boringstack/**), including via a templated dynamic import(). Inject the adapter behind its seam and wire it at a composition root (cli.ts, cli/**, scripts/**).", }, + { + selector: + 'CallExpression[callee.callee.name="createRequire"] > Literal[value=/(^|\\u002F)boringstack($|\\u002F)/]', + message: + "Core loop must not import the BoringStack adapter (loop/boringstack/**), including via createRequire(...)(). Inject the adapter behind its seam and wire it at a composition root (cli.ts, cli/**, scripts/**).", + }, ], }, } diff --git a/packages/core/tests/core-adapter-boundary.test.ts b/packages/core/tests/core-adapter-boundary.test.ts index a901f197..f3a98d18 100644 --- a/packages/core/tests/core-adapter-boundary.test.ts +++ b/packages/core/tests/core-adapter-boundary.test.ts @@ -86,6 +86,8 @@ test("the mechanical core↔adapter boundary rejects a core-loop import of loop/ 'export async function load() {\n return import("../boringstack/plan-extension");\n}\n', "leak-dynamic-template.ts": "export async function load() {\n return import(`../boringstack/plan-extension`);\n}\n", + "leak-require.ts": + 'import { createRequire } from "node:module";\n\nexport const m = createRequire(import.meta.url)("../boringstack/plan-extension");\n', "enum.ts": "export enum Color {\n Red,\n Blue,\n}\n", "core-ok.ts": 'import { isProductPlan } from "../planning/plan-store";\n\nexport const b = isProductPlan;\n', @@ -102,6 +104,10 @@ test("the mechanical core↔adapter boundary rejects a core-loop import of loop/ // A TEMPLATED dynamic import (`import(`../boringstack/x`)`) is caught by the TemplateElement // selector — the string-Literal-only form a prior round could evade is closed. expect(results.get("leak-dynamic-template.ts")).toContain(SYNTAX_RULE); + // An immediately-invoked createRequire(...)("../boringstack/x") (CommonJS-interop runtime load) + // is caught too — no-restricted-imports only sees the permitted `node:module` import, so the + // ban lives in the no-restricted-syntax createRequire selector. + expect(results.get("leak-require.ts")).toContain(SYNTAX_RULE); // The boundary block REPLACES the base no-restricted-syntax for loop files, re-including the // enum ban. Prove that ban still fires here, so a future edit dropping the TSEnumDeclaration // selector can't silently relax the gate for the whole core loop. From f572f8c7ee51ebd38edf7a6c46256ec74f6d1b2e Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 06:23:53 +0200 Subject: [PATCH 47/53] feat(core): unify dynamic-loader boundary selectors + cover every form with tests (panel r6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r6 BLOCK majors: - the createRequire selector matched only a DIRECT Literal arg, so createRequire(u)(`..`), createRequire(u)("../boringstack/"+n), and a ternary arg evaded it — despite each holding a statically-matchable boringstack segment (not the documented aliased/computed limit). - the test never exercised static re-exports or the claimed dynamic concat/ternary forms, so those could regress green. Fix: collapse the import()/createRequire selectors into two unified :matches() selectors over both loaders — a DESCENDANT boringstack Literal (string / concat / ternary) and a boringstack TemplateElement (templated) — so import() and createRequire(...)() are hardened identically. Regression test now covers static value + type + re-export, dynamic literal/template/concat/ ternary, createRequire literal/template, the enum ban still firing, and a silent core control. Also guard the eslint binary path with a clear existsSync error (minor r6 finding). Verified via probes: all eight leak forms fire; a computed import(var), an aliased-require, and a core-module load do not (documented inherent limit); real tree clean. --- eslint.config.js | 20 +++++------ .../core/tests/core-adapter-boundary.test.ts | 34 ++++++++++++++----- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 1549c7c3..fe84d4b5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -284,22 +284,22 @@ export default tseslint.config( message: "Use 'as const' object literals instead of enums.", }, { + // Any boringstack string Literal ANYWHERE in a runtime-loader argument — a dynamic + // `import(...)` OR an immediately-invoked `createRequire(...)(...)`. Descendant match, so + // it covers the string, a `"../boringstack/" + n` concat, and a ternary arg uniformly for + // BOTH loaders. selector: - "ImportExpression Literal[value=/(^|\\u002F)boringstack($|\\u002F)/]", + ':matches(ImportExpression, CallExpression[callee.callee.name="createRequire"]) Literal[value=/(^|\\u002F)boringstack($|\\u002F)/]', message: - "Core loop must not import the BoringStack adapter (loop/boringstack/**), including via dynamic import(). Inject the adapter behind its seam and wire it at a composition root (cli.ts, cli/**, scripts/**).", + "Core loop must not import the BoringStack adapter (loop/boringstack/**), including via a dynamic import() or createRequire(...)(). Inject the adapter behind its seam and wire it at a composition root (cli.ts, cli/**, scripts/**).", }, { + // The templated form of either loader (`import(`../boringstack/x`)` / + // `createRequire(u)(`../boringstack/x`)`). selector: - "ImportExpression TemplateElement[value.cooked=/(^|\\u002F)boringstack($|\\u002F)/]", + ':matches(ImportExpression, CallExpression[callee.callee.name="createRequire"]) TemplateElement[value.cooked=/(^|\\u002F)boringstack($|\\u002F)/]', message: - "Core loop must not import the BoringStack adapter (loop/boringstack/**), including via a templated dynamic import(). Inject the adapter behind its seam and wire it at a composition root (cli.ts, cli/**, scripts/**).", - }, - { - selector: - 'CallExpression[callee.callee.name="createRequire"] > Literal[value=/(^|\\u002F)boringstack($|\\u002F)/]', - message: - "Core loop must not import the BoringStack adapter (loop/boringstack/**), including via createRequire(...)(). Inject the adapter behind its seam and wire it at a composition root (cli.ts, cli/**, scripts/**).", + "Core loop must not import the BoringStack adapter (loop/boringstack/**), including via a templated dynamic import() or createRequire(...)(). Inject the adapter behind its seam and wire it at a composition root (cli.ts, cli/**, scripts/**).", }, ], }, diff --git a/packages/core/tests/core-adapter-boundary.test.ts b/packages/core/tests/core-adapter-boundary.test.ts index f3a98d18..466bfecb 100644 --- a/packages/core/tests/core-adapter-boundary.test.ts +++ b/packages/core/tests/core-adapter-boundary.test.ts @@ -1,6 +1,6 @@ import { test, expect } from "bun:test"; import { join } from "node:path"; -import { writeFileSync, rmSync, mkdirSync } from "node:fs"; +import { writeFileSync, rmSync, mkdirSync, existsSync } from "node:fs"; // WS4 — the core↔adapter law made MECHANICAL. eslint.config.js forbids the generic core loop // (`loop/**` except `loop/boringstack/**`) from importing the BoringStack adapter. WS1–WS3 @@ -36,6 +36,12 @@ interface IFileResult { const lintFixtures = ( files: Record ): Map => { + // Fail loudly + specifically if the eslint binary isn't where this repo puts it, rather than + // letting a failed spawn surface as an opaque empty-stdout JSON error. + if (!existsSync(ESLINT)) { + throw new Error(`eslint binary not found at ${ESLINT}`); + } + try { // Create + write INSIDE the try so the finally cleanup runs even if mkdir/write throws. mkdirSync(FIXTURE_DIR, { recursive: true }); @@ -82,12 +88,20 @@ test("the mechanical core↔adapter boundary rejects a core-loop import of loop/ 'import { boringstackPlanSchema } from "../boringstack/plan-extension";\n\nexport const a = boringstackPlanSchema;\n', "leak-type.ts": 'import type { IUiIntent } from "../boringstack/plan-extension";\n\nexport type A = IUiIntent;\n', + "leak-reexport.ts": + 'export { boringstackPlanSchema } from "../boringstack/plan-extension";\n', "leak-dynamic.ts": 'export async function load() {\n return import("../boringstack/plan-extension");\n}\n', "leak-dynamic-template.ts": "export async function load() {\n return import(`../boringstack/plan-extension`);\n}\n", + "leak-dynamic-concat.ts": + 'export async function load() {\n return import("../boringstack/" + "plan-extension");\n}\n', + "leak-dynamic-ternary.ts": + 'export async function load(b: boolean) {\n return import(b ? "../boringstack/plan-extension" : "../planning/plan-store");\n}\n', "leak-require.ts": 'import { createRequire } from "node:module";\n\nexport const m = createRequire(import.meta.url)("../boringstack/plan-extension");\n', + "leak-require-template.ts": + 'import { createRequire } from "node:module";\n\nexport const m = createRequire(import.meta.url)(`../boringstack/plan-extension`);\n', "enum.ts": "export enum Color {\n Red,\n Blue,\n}\n", "core-ok.ts": 'import { isProductPlan } from "../planning/plan-store";\n\nexport const b = isProductPlan;\n', @@ -98,16 +112,20 @@ test("the mechanical core↔adapter boundary rejects a core-loop import of loop/ // A TYPE-ONLY import of the adapter fires it too — the @typescript-eslint superset of // no-restricted-imports catches `import type`, which core no-restricted-imports would miss. expect(results.get("leak-type.ts")).toContain(RULE); - // A DYNAMIC import() of the adapter is caught too — by the no-restricted-syntax ImportExpression - // selector (no-restricted-imports doesn't see dynamic imports), so the boundary has no escape hatch. + // A static RE-EXPORT (`export { x } from "../boringstack/y"`) is a restricted import too. + expect(results.get("leak-reexport.ts")).toContain(RULE); + // DYNAMIC import() forms are caught by the no-restricted-syntax selectors (no-restricted-imports + // doesn't see dynamic imports): plain string, templated, string CONCAT, and a TERNARY arg — each + // a form a narrower selector could evade. expect(results.get("leak-dynamic.ts")).toContain(SYNTAX_RULE); - // A TEMPLATED dynamic import (`import(`../boringstack/x`)`) is caught by the TemplateElement - // selector — the string-Literal-only form a prior round could evade is closed. expect(results.get("leak-dynamic-template.ts")).toContain(SYNTAX_RULE); - // An immediately-invoked createRequire(...)("../boringstack/x") (CommonJS-interop runtime load) - // is caught too — no-restricted-imports only sees the permitted `node:module` import, so the - // ban lives in the no-restricted-syntax createRequire selector. + expect(results.get("leak-dynamic-concat.ts")).toContain(SYNTAX_RULE); + expect(results.get("leak-dynamic-ternary.ts")).toContain(SYNTAX_RULE); + // An immediately-invoked createRequire(...)(...) (CommonJS-interop runtime load) is caught too — + // no-restricted-imports only sees the permitted `node:module` import, so the ban lives in the + // no-restricted-syntax selectors, which cover the string AND templated createRequire forms. expect(results.get("leak-require.ts")).toContain(SYNTAX_RULE); + expect(results.get("leak-require-template.ts")).toContain(SYNTAX_RULE); // The boundary block REPLACES the base no-restricted-syntax for loop files, re-including the // enum ban. Prove that ban still fires here, so a future edit dropping the TSEnumDeclaration // selector can't silently relax the gate for the whole core loop. From 294e83a59cdaebcea7742598a810f25170f26181 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 06:37:02 +0200 Subject: [PATCH 48/53] test(core): cross-platform basename + document safe over-matches (panel r7 minors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r7 was PASS; these close its three agreement-1 minors: - use node:path basename() instead of split("/").pop() so eslint's absolute paths map to fixture basenames on Windows (backslash separators) too. - note in the test header that the intentional-violation fixtures live briefly under the real source tree but never collide with the project lint step (validate chains steps with &&, so lint finishes before the test writes anything). - document in eslint.config.js that the descendant createRequire selector also matches a boringstack literal in the createRequire(FILENAME) arg — a pathological over-match in the SAFE direction (rooting a require loader at a boringstack path is already adapter-adjacent; no real code does it). --- eslint.config.js | 5 ++++- packages/core/tests/core-adapter-boundary.test.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index fe84d4b5..d937a8dd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -287,7 +287,10 @@ export default tseslint.config( // Any boringstack string Literal ANYWHERE in a runtime-loader argument — a dynamic // `import(...)` OR an immediately-invoked `createRequire(...)(...)`. Descendant match, so // it covers the string, a `"../boringstack/" + n` concat, and a ternary arg uniformly for - // BOTH loaders. + // BOTH loaders. (Descendant also matches a boringstack literal in the createRequire(FILENAME) + // argument — e.g. `createRequire("/x/boringstack/y")("../core/z")` — an over-match, but a + // pathological one in the SAFE direction: rooting a require loader at a boringstack path is + // already adapter-adjacent, and no real code does it.) selector: ':matches(ImportExpression, CallExpression[callee.callee.name="createRequire"]) Literal[value=/(^|\\u002F)boringstack($|\\u002F)/]', message: diff --git a/packages/core/tests/core-adapter-boundary.test.ts b/packages/core/tests/core-adapter-boundary.test.ts index 466bfecb..7f2c1237 100644 --- a/packages/core/tests/core-adapter-boundary.test.ts +++ b/packages/core/tests/core-adapter-boundary.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { join } from "node:path"; +import { join, basename } from "node:path"; import { writeFileSync, rmSync, mkdirSync, existsSync } from "node:fs"; // WS4 — the core↔adapter law made MECHANICAL. eslint.config.js forbids the generic core loop @@ -9,6 +9,9 @@ import { writeFileSync, rmSync, mkdirSync, existsSync } from "node:fs"; // side of the boundary (under `loop/`, NOT under `loop/boringstack/`) and asserts the rule fires // on static / type-only / dynamic (literal + templated) adapter imports and stays silent on a core // import. One eslint spawn (one TS-program load) over all fixtures; the fixture dir is always removed. +// The intentional-violation fixtures live briefly under the real source tree (they MUST, to match the +// boundary rule's `loop/**` scope), but `bun run validate` chains its steps with `&&` — lint finishes +// before this test writes anything — so they never collide with the project lint step. const ROOT = join(import.meta.dir, "..", "..", ".."); const ESLINT = join(ROOT, "node_modules", ".bin", "eslint"); // Suffix the fixture dir with the worker PID so concurrent bun-test processes never share (and @@ -70,8 +73,9 @@ const lintFixtures = ( const byName = new Map(); for (const r of parsed) { + // basename() handles both / and \ separators, so keys match fixture basenames off Unix too. byName.set( - r.filePath.split("/").pop() ?? r.filePath, + basename(r.filePath), r.messages.map((m) => m.ruleId) ); } From 2bc49314b4cbe013e471b88b27bd7f4375a2f2e3 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 06:50:45 +0200 Subject: [PATCH 49/53] feat(core): orphan-safe boundary fixtures + adapter-exemption test + honest syntactic ceiling (panel r8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r8 findings: - MAJOR (orphan footgun): the test writes intentional-violation fixtures under the source tree; a SIGKILL/crash before `finally` would orphan a PID dir that then breaks every later `eslint packages`. Fix: globally IGNORE the `__adapter_boundary_*` fixture dirs in eslint.config.js (so an orphan is invisible to validate), and lint them in the test with `--no-ignore` to override. - MINOR (exemption untested): add an ADAPTER-side fixture under loop/boringstack/ that names a boringstack path — it must NOT fire, locking the boundary block's `ignores` (delete it → fires → fail). - MAJOR (more loader evasions): closed the one cheap named form — `module.createRequire(...)(...)` via a `callee.callee.property.name` selector (+ test). Reframed the config comment to stop over-claiming: a no-restricted-syntax matcher catches the DIRECT loader forms (import / createRequire / module.createRequire with a boringstack Literal or template quasi in the arg — string/concat/ternary/ template), which is the realistic coupling risk; it CANNOT follow a renamed import binding (createRequire as cr), resolve dataflow (const p=...; import(p)), or evaluate a segment-split concat — those need scope/type analysis, are deliberately obfuscated, and appear nowhere in this pure-ESM, zero-createRequire codebase. The static no-restricted-imports gate already stops every normal coupling. Test now covers static value/type/re-export, dynamic literal/template/concat/ternary, createRequire literal/template/member, the enum ban, a silent core control, AND the adapter-side exemption (17 asserts). --- eslint.config.js | 49 ++++--- .../core/tests/core-adapter-boundary.test.ts | 138 +++++++++++------- 2 files changed, 115 insertions(+), 72 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index d937a8dd..837264ce 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -16,7 +16,16 @@ import stylistic from "@stylistic/eslint-plugin"; */ export default tseslint.config( { - ignores: ["**/node_modules/**", "**/dist/**", "**/.astro/**", "apps/**"], + ignores: [ + "**/node_modules/**", + "**/dist/**", + "**/.astro/**", + "apps/**", + // Throwaway fixture dirs the core↔adapter boundary test writes under the source tree. Globally + // ignored so an ORPHAN (left by a SIGKILL/crash mid-test) can never break a later `eslint + // packages` run; the test itself lints them with `--no-ignore` to override this. + "**/__adapter_boundary_*/**", + ], }, { files: ["packages/**/*.ts"], @@ -262,21 +271,25 @@ export default tseslint.config( ], // no-restricted-imports covers STATIC import/export declarations but NOT runtime module loads // — a dynamic `import("../boringstack/x")` (ImportExpression) or a `createRequire(...)(...)` - // (CommonJS interop) — which would otherwise reach the adapter and bypass the boundary. Close - // every form whose target path is a STATIC STRING somewhere in the AST, via selectors on the - // loader argument: (1) any string Literal naming a boringstack segment ANYWHERE in an - // import() argument (covers `import("../boringstack/x")`, a ternary, and - // `import("../boringstack/" + n)`); (2) any template-literal quasi naming it (covers - // `import(`../boringstack/x`)`); (3) an immediately-invoked `createRequire(...)("...boringstack...")`. - // DOCUMENTED INHERENT LIMIT (not a gap to chase — the WS2 lesson: close what's statically - // matchable, document the rest): a path assembled at RUNTIME is beyond static AST matching for - // ANY lint rule — `import(runtimeVar)`, a segment-splitting concat `"../bor" + "ingstack/x"` - // where no single node holds the segment, or a require function ALIASED to a variable first - // (`const r = createRequire(u); r("../boringstack/x")`). None of these is how a known module is - // ever loaded; they are adversarial, and this is a pure-ESM codebase with zero `createRequire` - // use. NOTE: this whole rule REPLACES the base `no-restricted-syntax` (the enum ban) for loop - // files — flat config overrides a rule wholesale per key — so the enum selector is re-included - // here (and a test asserts it still fires in loop files). + // (CommonJS interop) — which would otherwise reach the adapter. The two selectors below catch + // the DIRECT, unobfuscated loader forms — where the boringstack path is a string Literal or a + // template quasi in the loader's own argument, and the loader is spelled `import` / + // `createRequire` / `module.createRequire`. That covers the realistic coupling risk: + // `import("../boringstack/x")`, its templated/concat/ternary variants, and the createRequire + // equivalents. + // + // WHAT A SYNTACTIC MATCHER CANNOT DO (honest ceiling, per the WS2 lesson — close the direct + // forms, don't chase adversarial obfuscation a `no-restricted-syntax` selector fundamentally + // can't reach; a lint rule is a coupling guardrail, not an adversarial sandbox): it cannot + // follow a RENAMED import binding (`import { createRequire as cr } ...; cr(u)(path)`), resolve + // DATAFLOW (`const p = "../boringstack/x"; import(p)`), or evaluate a segment-splitting concat + // (`"../bor" + "ingstack/x"`) where no single node holds the segment. Catching those needs + // scope/type analysis (a custom type-aware rule) — out of proportion to forms that are + // deliberately obfuscated and appear NOWHERE in this pure-ESM, zero-createRequire codebase; the + // static import/no-restricted-imports gate already stops every normal way a file couples to the + // adapter. NOTE: this whole rule REPLACES the base `no-restricted-syntax` (the enum ban) for + // loop files — flat config overrides a rule wholesale per key — so the enum selector is + // re-included here (and a test asserts it still fires in loop files). "no-restricted-syntax": [ "error", { @@ -292,7 +305,7 @@ export default tseslint.config( // pathological one in the SAFE direction: rooting a require loader at a boringstack path is // already adapter-adjacent, and no real code does it.) selector: - ':matches(ImportExpression, CallExpression[callee.callee.name="createRequire"]) Literal[value=/(^|\\u002F)boringstack($|\\u002F)/]', + ':matches(ImportExpression, CallExpression[callee.callee.name="createRequire"], CallExpression[callee.callee.property.name="createRequire"]) Literal[value=/(^|\\u002F)boringstack($|\\u002F)/]', message: "Core loop must not import the BoringStack adapter (loop/boringstack/**), including via a dynamic import() or createRequire(...)(). Inject the adapter behind its seam and wire it at a composition root (cli.ts, cli/**, scripts/**).", }, @@ -300,7 +313,7 @@ export default tseslint.config( // The templated form of either loader (`import(`../boringstack/x`)` / // `createRequire(u)(`../boringstack/x`)`). selector: - ':matches(ImportExpression, CallExpression[callee.callee.name="createRequire"]) TemplateElement[value.cooked=/(^|\\u002F)boringstack($|\\u002F)/]', + ':matches(ImportExpression, CallExpression[callee.callee.name="createRequire"], CallExpression[callee.callee.property.name="createRequire"]) TemplateElement[value.cooked=/(^|\\u002F)boringstack($|\\u002F)/]', message: "Core loop must not import the BoringStack adapter (loop/boringstack/**), including via a templated dynamic import() or createRequire(...)(). Inject the adapter behind its seam and wire it at a composition root (cli.ts, cli/**, scripts/**).", }, diff --git a/packages/core/tests/core-adapter-boundary.test.ts b/packages/core/tests/core-adapter-boundary.test.ts index 7f2c1237..55ee8308 100644 --- a/packages/core/tests/core-adapter-boundary.test.ts +++ b/packages/core/tests/core-adapter-boundary.test.ts @@ -5,28 +5,29 @@ import { writeFileSync, rmSync, mkdirSync, existsSync } from "node:fs"; // WS4 — the core↔adapter law made MECHANICAL. eslint.config.js forbids the generic core loop // (`loop/**` except `loop/boringstack/**`) from importing the BoringStack adapter. WS1–WS3 // reclaimed the leaks by hand; this test proves the rule that keeps them reclaimed is LIVE — a -// leak fails `bun run validate`, not just review. It lints throwaway fixtures placed on the CORE -// side of the boundary (under `loop/`, NOT under `loop/boringstack/`) and asserts the rule fires -// on static / type-only / dynamic (literal + templated) adapter imports and stays silent on a core -// import. One eslint spawn (one TS-program load) over all fixtures; the fixture dir is always removed. -// The intentional-violation fixtures live briefly under the real source tree (they MUST, to match the -// boundary rule's `loop/**` scope), but `bun run validate` chains its steps with `&&` — lint finishes -// before this test writes anything — so they never collide with the project lint step. +// leak fails `bun run validate`, not just review. It lints throwaway fixtures on the CORE side of +// the boundary (asserting adapter imports are rejected and a core import is not) AND on the ADAPTER +// side (asserting `loop/boringstack/**` is exempt via the `ignores`). One eslint spawn; both dirs +// are always removed. +// +// The fixtures must live under `loop/` to match the boundary rule's scope, but they carry +// deliberate violations — so the fixture dirs are GLOBALLY IGNORED in eslint.config.js (see the +// `__adapter_boundary_*` ignore). That makes an ORPHANED dir (SIGKILL/crash before the `finally` +// runs) invisible to a normal `eslint packages`, so it can't break a later validate; this test +// overrides the ignore with `--no-ignore` to lint the fixtures on purpose. const ROOT = join(import.meta.dir, "..", "..", ".."); const ESLINT = join(ROOT, "node_modules", ".bin", "eslint"); -// Suffix the fixture dir with the worker PID so concurrent bun-test processes never share (and -// race on write/lint/rm of) the same directory. -const FIXTURE_DIR = join( - ROOT, - "packages", - "core", - "src", - "loop", - `__adapter_boundary_fixture_${process.pid}__` +const LOOP = join(ROOT, "packages", "core", "src", "loop"); +// PID-suffixed so concurrent bun-test processes never share (and race on) the same directory. +const CORE_DIR = join(LOOP, `__adapter_boundary_fixture_${process.pid}__`); +const ADAPTER_DIR = join( + LOOP, + "boringstack", + `__adapter_boundary_exempt_${process.pid}__` ); const RULE = "@typescript-eslint/no-restricted-imports"; -// The dynamic-import escape hatch (`import("../boringstack/x")`) is caught by a no-restricted-syntax -// AST selector, not no-restricted-imports, so it reports under a different ruleId. +// The runtime-loader escape hatches (dynamic import(), createRequire) are caught by +// no-restricted-syntax AST selectors, not no-restricted-imports, so they report under this ruleId. const SYNTAX_RULE = "no-restricted-syntax"; interface IFileResult { @@ -34,10 +35,12 @@ interface IFileResult { readonly messages: readonly { readonly ruleId: string | null }[]; } -/** Write the given `{ name: source }` fixtures under FIXTURE_DIR, lint the dir ONCE, and return a - * map of basename → the ruleIds eslint reported for that file. Always cleans up. */ +/** Write `core` fixtures under CORE_DIR and `adapter` fixtures under ADAPTER_DIR, lint both dirs in + * ONE eslint run (with `--no-ignore`, since the dirs are globally ignored), and return a map of + * basename → the ruleIds eslint reported. Always cleans up both dirs. */ const lintFixtures = ( - files: Record + core: Record, + adapter: Record ): Map => { // Fail loudly + specifically if the eslint binary isn't where this repo puts it, rather than // letting a failed spawn surface as an opaque empty-stdout JSON error. @@ -47,15 +50,21 @@ const lintFixtures = ( try { // Create + write INSIDE the try so the finally cleanup runs even if mkdir/write throws. - mkdirSync(FIXTURE_DIR, { recursive: true }); + for (const [dir, files] of [ + [CORE_DIR, core], + [ADAPTER_DIR, adapter], + ] as const) { + mkdirSync(dir, { recursive: true }); - for (const [name, source] of Object.entries(files)) { - writeFileSync(join(FIXTURE_DIR, name), source); + for (const [name, source] of Object.entries(files)) { + writeFileSync(join(dir, name), source); + } } - const proc = Bun.spawnSync([ESLINT, "--format", "json", FIXTURE_DIR], { - cwd: ROOT, - }); + const proc = Bun.spawnSync( + [ESLINT, "--no-ignore", "--format", "json", CORE_DIR, ADAPTER_DIR], + { cwd: ROOT } + ); const stdout = proc.stdout.toString(); // eslint EXITS NON-ZERO here (the fixtures have lint errors — that's the point), but still // writes its JSON report to stdout. A crash (missing binary, config load failure) instead @@ -82,34 +91,46 @@ const lintFixtures = ( return byName; } finally { - rmSync(FIXTURE_DIR, { recursive: true, force: true }); + rmSync(CORE_DIR, { recursive: true, force: true }); + rmSync(ADAPTER_DIR, { recursive: true, force: true }); } }; -test("the mechanical core↔adapter boundary rejects a core-loop import of loop/boringstack (value + type + dynamic) and allows a core import", () => { - const results = lintFixtures({ - "leak-value.ts": - 'import { boringstackPlanSchema } from "../boringstack/plan-extension";\n\nexport const a = boringstackPlanSchema;\n', - "leak-type.ts": - 'import type { IUiIntent } from "../boringstack/plan-extension";\n\nexport type A = IUiIntent;\n', - "leak-reexport.ts": - 'export { boringstackPlanSchema } from "../boringstack/plan-extension";\n', - "leak-dynamic.ts": - 'export async function load() {\n return import("../boringstack/plan-extension");\n}\n', - "leak-dynamic-template.ts": - "export async function load() {\n return import(`../boringstack/plan-extension`);\n}\n", - "leak-dynamic-concat.ts": - 'export async function load() {\n return import("../boringstack/" + "plan-extension");\n}\n', - "leak-dynamic-ternary.ts": - 'export async function load(b: boolean) {\n return import(b ? "../boringstack/plan-extension" : "../planning/plan-store");\n}\n', - "leak-require.ts": - 'import { createRequire } from "node:module";\n\nexport const m = createRequire(import.meta.url)("../boringstack/plan-extension");\n', - "leak-require-template.ts": - 'import { createRequire } from "node:module";\n\nexport const m = createRequire(import.meta.url)(`../boringstack/plan-extension`);\n', - "enum.ts": "export enum Color {\n Red,\n Blue,\n}\n", - "core-ok.ts": - 'import { isProductPlan } from "../planning/plan-store";\n\nexport const b = isProductPlan;\n', - }); +test("the mechanical core↔adapter boundary rejects core-loop adapter imports (static/dynamic/require), allows a core import, and exempts the adapter itself", () => { + const results = lintFixtures( + { + "leak-value.ts": + 'import { boringstackPlanSchema } from "../boringstack/plan-extension";\n\nexport const a = boringstackPlanSchema;\n', + "leak-type.ts": + 'import type { IUiIntent } from "../boringstack/plan-extension";\n\nexport type A = IUiIntent;\n', + "leak-reexport.ts": + 'export { boringstackPlanSchema } from "../boringstack/plan-extension";\n', + "leak-dynamic.ts": + 'export async function load() {\n return import("../boringstack/plan-extension");\n}\n', + "leak-dynamic-template.ts": + "export async function load() {\n return import(`../boringstack/plan-extension`);\n}\n", + "leak-dynamic-concat.ts": + 'export async function load() {\n return import("../boringstack/" + "plan-extension");\n}\n', + "leak-dynamic-ternary.ts": + 'export async function load(b: boolean) {\n return import(b ? "../boringstack/plan-extension" : "../planning/plan-store");\n}\n', + "leak-require.ts": + 'import { createRequire } from "node:module";\n\nexport const m = createRequire(import.meta.url)("../boringstack/plan-extension");\n', + "leak-require-template.ts": + 'import { createRequire } from "node:module";\n\nexport const m = createRequire(import.meta.url)(`../boringstack/plan-extension`);\n', + "leak-require-member.ts": + 'import * as mod from "node:module";\n\nexport const m = mod.createRequire(import.meta.url)("../boringstack/plan-extension");\n', + "enum.ts": "export enum Color {\n Red,\n Blue,\n}\n", + "core-ok.ts": + 'import { isProductPlan } from "../planning/plan-store";\n\nexport const b = isProductPlan;\n', + }, + { + // ADAPTER side (loop/boringstack/**): a boringstack-naming import that WOULD trip the boundary + // rule if this file were in scope — but the boundary block's `ignores` exempts the adapter, so + // it must NOT fire. Locks the exemption: deleting the `ignores` line makes this fire → test fails. + "adapter-internal.ts": + 'import { boringstackPlanSchema } from "../../boringstack/plan-extension";\n\nexport const a = boringstackPlanSchema;\n', + } + ); // A value import of the adapter fires the boundary rule. expect(results.get("leak-value.ts")).toContain(RULE); @@ -127,9 +148,11 @@ test("the mechanical core↔adapter boundary rejects a core-loop import of loop/ expect(results.get("leak-dynamic-ternary.ts")).toContain(SYNTAX_RULE); // An immediately-invoked createRequire(...)(...) (CommonJS-interop runtime load) is caught too — // no-restricted-imports only sees the permitted `node:module` import, so the ban lives in the - // no-restricted-syntax selectors, which cover the string AND templated createRequire forms. + // no-restricted-syntax selectors, which cover the string, templated, AND `module.createRequire` + // member forms. expect(results.get("leak-require.ts")).toContain(SYNTAX_RULE); expect(results.get("leak-require-template.ts")).toContain(SYNTAX_RULE); + expect(results.get("leak-require-member.ts")).toContain(SYNTAX_RULE); // The boundary block REPLACES the base no-restricted-syntax for loop files, re-including the // enum ban. Prove that ban still fires here, so a future edit dropping the TSEnumDeclaration // selector can't silently relax the gate for the whole core loop. @@ -137,10 +160,17 @@ test("the mechanical core↔adapter boundary rejects a core-loop import of loop/ // Importing another CORE module is allowed — the rule targets only the adapter subtree, so it // is a real boundary, not a blanket ban that would also block legitimate intra-core imports. // Assert the control file was ACTUALLY linted first (a missing entry — ignored / mis-scoped / - // basename mismatch — must fail, not green-wash the negative case via a `?? []` default). + // basename mismatch — must fail, not green-wash the negative case). const coreOk = results.get("core-ok.ts"); expect(coreOk).toBeDefined(); expect(coreOk).not.toContain(RULE); expect(coreOk).not.toContain(SYNTAX_RULE); + // The ADAPTER'S OWN file is exempt (via the boundary block's `ignores`) even though it names a + // boringstack path — proven linted (defined) and not flagged. This locks the `ignores` line. + const adapterInternal = results.get("adapter-internal.ts"); + + expect(adapterInternal).toBeDefined(); + expect(adapterInternal).not.toContain(RULE); + expect(adapterInternal).not.toContain(SYNTAX_RULE); }, 30000); From 929f87d2cf7d9831197e65d08f4b3893cbadabf3 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 07:05:44 +0200 Subject: [PATCH 50/53] test(core): scope the fixture ignore + prove it works (panel r9 minors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tighten the global fixture ignore from a bare `**/__adapter_boundary_*/**` to `packages/core/src/loop/**/__adapter_boundary_*/**`, so it can only ever exempt the boundary test's own fixture dirs under loop/ — not any checked-in code elsewhere that happened to share the sentinel name (which would have silently bypassed the whole lint gate). - add a second test that lints a fixture WITHOUT --no-ignore (as `bun run validate` does) and asserts eslint reports it as IGNORED — proving the crash-orphan-safety ignore pattern actually matches, so a broken/ineffective pattern can no longer pass the suite. --- eslint.config.js | 9 ++-- .../core/tests/core-adapter-boundary.test.ts | 50 ++++++++++++++++++- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 837264ce..ac59598d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -21,10 +21,11 @@ export default tseslint.config( "**/dist/**", "**/.astro/**", "apps/**", - // Throwaway fixture dirs the core↔adapter boundary test writes under the source tree. Globally - // ignored so an ORPHAN (left by a SIGKILL/crash mid-test) can never break a later `eslint - // packages` run; the test itself lints them with `--no-ignore` to override this. - "**/__adapter_boundary_*/**", + // Throwaway fixture dirs the core↔adapter boundary test writes under `loop/`. Ignored so an + // ORPHAN (left by a SIGKILL/crash mid-test) can never break a later `eslint packages` run; the + // test itself lints them with `--no-ignore` to override this. SCOPED to the exact test location + // (not a bare `**/…` glob) so it can't shadow real code that happens to share the sentinel name. + "packages/core/src/loop/**/__adapter_boundary_*/**", ], }, { diff --git a/packages/core/tests/core-adapter-boundary.test.ts b/packages/core/tests/core-adapter-boundary.test.ts index 55ee8308..e9f80960 100644 --- a/packages/core/tests/core-adapter-boundary.test.ts +++ b/packages/core/tests/core-adapter-boundary.test.ts @@ -32,7 +32,10 @@ const SYNTAX_RULE = "no-restricted-syntax"; interface IFileResult { readonly filePath: string; - readonly messages: readonly { readonly ruleId: string | null }[]; + readonly messages: readonly { + readonly ruleId: string | null; + readonly message: string; + }[]; } /** Write `core` fixtures under CORE_DIR and `adapter` fixtures under ADAPTER_DIR, lint both dirs in @@ -174,3 +177,48 @@ test("the mechanical core↔adapter boundary rejects core-loop adapter imports ( expect(adapterInternal).not.toContain(RULE); expect(adapterInternal).not.toContain(SYNTAX_RULE); }, 30000); + +test("the fixture dirs are GLOBALLY IGNORED (crash-orphan safety): a leak there is skipped by a normal lint", () => { + // The main test lints with `--no-ignore`, so it never proves the global ignore actually works. A + // broken/ineffective ignore pattern would leave an orphaned crash fixture linted by every later + // `eslint packages` — a real footgun. Here we lint a fixture WITHOUT `--no-ignore` (as validate + // does) and assert eslint reports it as IGNORED, not as a boundary violation. + if (!existsSync(ESLINT)) { + throw new Error(`eslint binary not found at ${ESLINT}`); + } + + const file = join(CORE_DIR, "orphan-leak.ts"); + + try { + mkdirSync(CORE_DIR, { recursive: true }); + writeFileSync( + file, + 'import { boringstackPlanSchema } from "../boringstack/plan-extension";\n\nexport const a = boringstackPlanSchema;\n' + ); + + const proc = Bun.spawnSync([ESLINT, "--format", "json", file], { + cwd: ROOT, + }); + let parsed: IFileResult[]; + + try { + parsed = JSON.parse(proc.stdout.toString()); + } catch { + throw new Error( + `eslint did not emit a JSON report (exit ${proc.exitCode}). stderr: ${proc.stderr.toString()} | stdout: ${proc.stdout.toString()}` + ); + } + + const messages = parsed.flatMap((r) => r.messages); + const ruleIds = messages.map((m) => m.ruleId); + + // The boundary rules must NOT have run (the file was ignored, not linted)… + expect(ruleIds).not.toContain(RULE); + expect(ruleIds).not.toContain(SYNTAX_RULE); + // …and eslint must positively report it as ignored, proving the ignore PATTERN matched (a clean + // file with no messages could also satisfy the negative asserts above — this rules that out). + expect(messages.some((m) => m.message.includes("ignored"))).toBe(true); + } finally { + rmSync(CORE_DIR, { recursive: true, force: true }); + } +}, 30000); From 9e42b93ffc4c78b984b91d22489d475bd596f321 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 07:27:46 +0200 Subject: [PATCH 51/53] =?UTF-8?q?refactor(core):=20WS5=20=E2=80=94=20reloc?= =?UTF-8?q?ate=20the=20acceptance=20subsystem=20into=20the=20BoringStack?= =?UTF-8?q?=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WS3 documented follow-up. The acceptance/e2e-generation subsystem (acceptance-spec, acceptance.types, acceptance-outcome, acceptance-steer) was still in core `loop/acceptance/` but is WEB/SaaS-shaped (nav/shows/screens, testids, negatives-from-rules) and — confirmed by a full importer scan — consumed EXCLUSIVELY by the BoringStack adapter (build.ts, db-oracle, gate-stages, and the e2e-generator/e2e-runner/testid-contract already under loop/boringstack/acceptance/). ZERO core-loop files import it. Move the 4 files into loop/boringstack/acceptance/ (beside their only consumers) and rewrite the import paths (moved acceptance-spec now reaches core planning via ../../planning; the 6 boringstack importers and 12 test files updated). Core now keeps only the generic plan spine (IProductPlan/ISlice in planning/). No behaviour change — pure relocation; the acceptance + boringstack unit suites (206 tests) and the WS4 boundary test all pass, and `bun run validate` is green, so the core↔adapter lint boundary holds after moving a whole subsystem across it. --- .../acceptance/acceptance-outcome.ts | 0 .../acceptance/acceptance-spec.ts | 21 ++++++++++++------- .../acceptance/acceptance-steer.ts | 0 .../acceptance/acceptance.types.ts | 0 .../boringstack/acceptance/e2e-generator.ts | 4 ++-- .../loop/boringstack/acceptance/e2e-runner.ts | 4 ++-- .../boringstack/acceptance/testid-contract.ts | 4 ++-- packages/core/src/loop/boringstack/build.ts | 6 +++--- .../core/src/loop/boringstack/db-oracle.ts | 2 +- .../core/src/loop/boringstack/gate-stages.ts | 2 +- packages/core/tests/acceptance-result.test.ts | 4 ++-- packages/core/tests/acceptance-spec.test.ts | 4 ++-- packages/core/tests/acceptance-steer.test.ts | 4 ++-- .../boringstack-acceptance-wiring.test.ts | 2 +- packages/core/tests/boringstack-build.test.ts | 2 +- .../tests/boringstack-e2e-generator.test.ts | 4 ++-- .../core/tests/boringstack-e2e-runner.test.ts | 2 +- .../boringstack-final-acceptance.test.ts | 2 +- .../tests/boringstack-gate-stages.test.ts | 4 ++-- .../tests/boringstack-testid-contract.test.ts | 4 ++-- packages/core/tests/db-oracle.test.ts | 2 +- packages/core/tests/gate-honesty.test.ts | 2 +- 22 files changed, 42 insertions(+), 37 deletions(-) rename packages/core/src/loop/{ => boringstack}/acceptance/acceptance-outcome.ts (100%) rename packages/core/src/loop/{ => boringstack}/acceptance/acceptance-spec.ts (92%) rename packages/core/src/loop/{ => boringstack}/acceptance/acceptance-steer.ts (100%) rename packages/core/src/loop/{ => boringstack}/acceptance/acceptance.types.ts (100%) diff --git a/packages/core/src/loop/acceptance/acceptance-outcome.ts b/packages/core/src/loop/boringstack/acceptance/acceptance-outcome.ts similarity index 100% rename from packages/core/src/loop/acceptance/acceptance-outcome.ts rename to packages/core/src/loop/boringstack/acceptance/acceptance-outcome.ts diff --git a/packages/core/src/loop/acceptance/acceptance-spec.ts b/packages/core/src/loop/boringstack/acceptance/acceptance-spec.ts similarity index 92% rename from packages/core/src/loop/acceptance/acceptance-spec.ts rename to packages/core/src/loop/boringstack/acceptance/acceptance-spec.ts index 3ac4e852..7d969a51 100644 --- a/packages/core/src/loop/acceptance/acceptance-spec.ts +++ b/packages/core/src/loop/boringstack/acceptance/acceptance-spec.ts @@ -1,4 +1,8 @@ -import type { IProductPlan, ISlice, IEntitySpec } from "../planning/plan-types"; +import type { + IProductPlan, + ISlice, + IEntitySpec, +} from "../../planning/plan-types"; import type { IAcceptanceSpec, IEntityAcceptance, @@ -9,13 +13,14 @@ import type { } from "./acceptance.types"; /** The UI fields acceptance generation projects from a slice's `ui` — nav label, shown fields, and - * the screen list. HONEST SCOPE: `nav`/`shows`/`screens` are a WEB/SaaS-flavoured projection, and - * this whole acceptance-generation module is consumed only by the BoringStack adapter (build.ts, - * testid-contract, e2e-generator). The `uiFields` extractor removes core's dependence on the - * concrete UI-intent TYPE (that leak is what WS3 reclaimed from the plan spine), but it does NOT - * make e2e generation stack-neutral — a UI-less adapter (Phaser) would still have to manufacture - * these. Relocating the acceptance subsystem into `loop/boringstack/` is tracked as follow-up; it - * is downstream of the plan spine, not part of it. */ + * the screen list. `nav`/`shows`/`screens` are a WEB/SaaS-flavoured projection, and this whole + * acceptance-generation module is consumed only by the BoringStack adapter (build.ts, + * testid-contract, e2e-generator) — which is why the subsystem now LIVES here, under + * `loop/boringstack/acceptance/` (WS5 relocated it out of core `loop/acceptance/`; WS3 had already + * removed the plan-spine's dependence on the concrete UI-intent TYPE via the `uiFields` extractor). + * It stays generic over `TUi` (a pure projection helper), but it is NOT stack-neutral — a UI-less + * adapter (Phaser) would have to manufacture nav/shows/screens or bring its own acceptance scheme. + * Core keeps only the generic plan spine (`IProductPlan`/`ISlice` in `planning/`). */ export interface IAcceptanceUiFields { readonly nav: string; readonly shows: readonly string[]; diff --git a/packages/core/src/loop/acceptance/acceptance-steer.ts b/packages/core/src/loop/boringstack/acceptance/acceptance-steer.ts similarity index 100% rename from packages/core/src/loop/acceptance/acceptance-steer.ts rename to packages/core/src/loop/boringstack/acceptance/acceptance-steer.ts diff --git a/packages/core/src/loop/acceptance/acceptance.types.ts b/packages/core/src/loop/boringstack/acceptance/acceptance.types.ts similarity index 100% rename from packages/core/src/loop/acceptance/acceptance.types.ts rename to packages/core/src/loop/boringstack/acceptance/acceptance.types.ts diff --git a/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts b/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts index ed7eef2c..1656fc33 100644 --- a/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts +++ b/packages/core/src/loop/boringstack/acceptance/e2e-generator.ts @@ -3,8 +3,8 @@ import type { IEntityAcceptance, IAcceptanceSpec, IParentRef, -} from "../../acceptance/acceptance.types"; -import { testIdsFor } from "../../acceptance/acceptance-spec"; +} from "./acceptance.types"; +import { testIdsFor } from "./acceptance-spec"; /** * Generate a canonical test title for a given acceptance step. diff --git a/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts b/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts index 7ebcccb2..16211b53 100644 --- a/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts +++ b/packages/core/src/loop/boringstack/acceptance/e2e-runner.ts @@ -21,8 +21,8 @@ import type { IAcceptanceRunCtx, IEntityAcceptance, IAcceptanceSpec, -} from "../../acceptance/acceptance.types"; -import { summarize } from "../../acceptance/acceptance-outcome"; +} from "./acceptance.types"; +import { summarize } from "./acceptance-outcome"; /** * Type guard to validate a Playwright JSON report structure (nested suites format). diff --git a/packages/core/src/loop/boringstack/acceptance/testid-contract.ts b/packages/core/src/loop/boringstack/acceptance/testid-contract.ts index 31a6a2d5..2f355db1 100644 --- a/packages/core/src/loop/boringstack/acceptance/testid-contract.ts +++ b/packages/core/src/loop/boringstack/acceptance/testid-contract.ts @@ -1,5 +1,5 @@ -import type { IEntityAcceptance } from "../../acceptance/acceptance.types"; -import { testIdsFor } from "../../acceptance/acceptance-spec"; +import type { IEntityAcceptance } from "./acceptance.types"; +import { testIdsFor } from "./acceptance-spec"; /** * Compute the COMPLETE set of required testids for an entity. diff --git a/packages/core/src/loop/boringstack/build.ts b/packages/core/src/loop/boringstack/build.ts index 8ed903f9..7cd64074 100644 --- a/packages/core/src/loop/boringstack/build.ts +++ b/packages/core/src/loop/boringstack/build.ts @@ -30,7 +30,7 @@ import { type BoringstackProductPlan, type IUiIntent, } from "./plan-extension"; -import { planToAcceptanceSpec } from "../acceptance/acceptance-spec"; +import { planToAcceptanceSpec } from "./acceptance/acceptance-spec"; import { buildTestIdGuide } from "./acceptance/testid-contract"; import type { IEntityAcceptance, @@ -38,8 +38,8 @@ import type { IAcceptanceRunCtx, IAcceptanceSpec, IAcceptanceOutcome, -} from "../acceptance/acceptance.types"; -import { acceptanceSteer } from "../acceptance/acceptance-steer"; +} from "./acceptance/acceptance.types"; +import { acceptanceSteer } from "./acceptance/acceptance-steer"; import { readHostPorts, hostPortOr } from "../../scaffold"; import { FLAG_ON, ENV_FLAG } from "../../config/config.constants"; diff --git a/packages/core/src/loop/boringstack/db-oracle.ts b/packages/core/src/loop/boringstack/db-oracle.ts index 999961e4..0c94a295 100644 --- a/packages/core/src/loop/boringstack/db-oracle.ts +++ b/packages/core/src/loop/boringstack/db-oracle.ts @@ -1,4 +1,4 @@ -import type { IEntityAcceptance } from "../acceptance/acceptance.types"; +import type { IEntityAcceptance } from "./acceptance/acceptance.types"; import type { IErrorItem } from "../../validate"; import type { Exec } from "./exec"; diff --git a/packages/core/src/loop/boringstack/gate-stages.ts b/packages/core/src/loop/boringstack/gate-stages.ts index 0b9f5c9e..65ef3247 100644 --- a/packages/core/src/loop/boringstack/gate-stages.ts +++ b/packages/core/src/loop/boringstack/gate-stages.ts @@ -27,7 +27,7 @@ import { readResourceCode, rescueFileFor, } from "./build"; -import type { IEntityAcceptance } from "../acceptance/acceptance.types"; +import type { IEntityAcceptance } from "./acceptance/acceptance.types"; import { checkTestIds, checkWiring } from "./acceptance/testid-contract"; import { FLAG_ON, ENV_FLAG } from "../../config/config.constants"; diff --git a/packages/core/tests/acceptance-result.test.ts b/packages/core/tests/acceptance-result.test.ts index ca223a12..a2be42a4 100644 --- a/packages/core/tests/acceptance-result.test.ts +++ b/packages/core/tests/acceptance-result.test.ts @@ -1,6 +1,6 @@ import { test, expect } from "bun:test"; -import { summarize } from "../src/loop/acceptance/acceptance-outcome"; -import type { IAcceptanceResult } from "../src/loop/acceptance/acceptance.types"; +import { summarize } from "../src/loop/boringstack/acceptance/acceptance-outcome"; +import type { IAcceptanceResult } from "../src/loop/boringstack/acceptance/acceptance.types"; test("summarize: empty results → ok=false", () => { const outcome = summarize([]); diff --git a/packages/core/tests/acceptance-spec.test.ts b/packages/core/tests/acceptance-spec.test.ts index 19c12e8b..0147a8ef 100644 --- a/packages/core/tests/acceptance-spec.test.ts +++ b/packages/core/tests/acceptance-spec.test.ts @@ -4,8 +4,8 @@ import { planToAcceptanceSpec, testIdsFor, fieldIsMentioned, -} from "../src/loop/acceptance/acceptance-spec"; -import type { IAcceptField } from "../src/loop/acceptance/acceptance.types"; +} from "../src/loop/boringstack/acceptance/acceptance-spec"; +import type { IAcceptField } from "../src/loop/boringstack/acceptance/acceptance.types"; import { type IUiIntent } from "../src/loop/boringstack/plan-extension"; // The generic acceptance generator takes an injected UI-field extractor; these tests use the diff --git a/packages/core/tests/acceptance-steer.test.ts b/packages/core/tests/acceptance-steer.test.ts index 21eb6ac8..64d2c0f9 100644 --- a/packages/core/tests/acceptance-steer.test.ts +++ b/packages/core/tests/acceptance-steer.test.ts @@ -1,9 +1,9 @@ import { test, expect } from "bun:test"; -import { acceptanceSteer } from "../src/loop/acceptance/acceptance-steer"; +import { acceptanceSteer } from "../src/loop/boringstack/acceptance/acceptance-steer"; import type { IEntityAcceptance, IAcceptanceOutcome, -} from "../src/loop/acceptance/acceptance.types"; +} from "../src/loop/boringstack/acceptance/acceptance.types"; const makeEntity = (id: string): IEntityAcceptance => ({ id, diff --git a/packages/core/tests/boringstack-acceptance-wiring.test.ts b/packages/core/tests/boringstack-acceptance-wiring.test.ts index 9bcae31d..f2751455 100644 --- a/packages/core/tests/boringstack-acceptance-wiring.test.ts +++ b/packages/core/tests/boringstack-acceptance-wiring.test.ts @@ -3,7 +3,7 @@ import type { IAcceptanceOutcome, IAcceptanceRunner, IEntityAcceptance, -} from "../src/loop/acceptance/acceptance.types"; +} from "../src/loop/boringstack/acceptance/acceptance.types"; import { boringstackDeps } from "../src/loop/boringstack/build"; import type { IFeature, diff --git a/packages/core/tests/boringstack-build.test.ts b/packages/core/tests/boringstack-build.test.ts index 98c35cd4..45ac6e92 100644 --- a/packages/core/tests/boringstack-build.test.ts +++ b/packages/core/tests/boringstack-build.test.ts @@ -26,7 +26,7 @@ import type { IAcceptanceRunner, IAcceptanceOutcome, IEntityAcceptance, -} from "../src/loop/acceptance/acceptance.types"; +} from "../src/loop/boringstack/acceptance/acceptance.types"; import type { IProvider } from "../src/inference"; import type { IGate } from "../src/gate/gate-runner"; import { writePlan } from "../src/loop/planning/plan-store"; diff --git a/packages/core/tests/boringstack-e2e-generator.test.ts b/packages/core/tests/boringstack-e2e-generator.test.ts index 8dd19294..0bf1f7d1 100644 --- a/packages/core/tests/boringstack-e2e-generator.test.ts +++ b/packages/core/tests/boringstack-e2e-generator.test.ts @@ -5,12 +5,12 @@ import { generateChainSpec, chainSpecPath, } from "../src/loop/boringstack/acceptance/e2e-generator"; -import { planToAcceptanceSpec } from "../src/loop/acceptance/acceptance-spec"; +import { planToAcceptanceSpec } from "../src/loop/boringstack/acceptance/acceptance-spec"; import type { IEntityAcceptance, IAcceptanceSpec, IAcceptField, -} from "../src/loop/acceptance/acceptance.types"; +} from "../src/loop/boringstack/acceptance/acceptance.types"; import type { IProductPlan } from "../src/loop/planning/plan-types"; import { boringstackUiFields, diff --git a/packages/core/tests/boringstack-e2e-runner.test.ts b/packages/core/tests/boringstack-e2e-runner.test.ts index 3f7d421e..80961ce8 100644 --- a/packages/core/tests/boringstack-e2e-runner.test.ts +++ b/packages/core/tests/boringstack-e2e-runner.test.ts @@ -12,7 +12,7 @@ import { chainCreateTitle } from "../src/loop/boringstack/acceptance/e2e-generat import type { IEntityAcceptance, IAcceptanceRunCtx, -} from "../src/loop/acceptance/acceptance.types"; +} from "../src/loop/boringstack/acceptance/acceptance.types"; /** * Create a test context with a temporary directory. diff --git a/packages/core/tests/boringstack-final-acceptance.test.ts b/packages/core/tests/boringstack-final-acceptance.test.ts index 502a5549..71c9361e 100644 --- a/packages/core/tests/boringstack-final-acceptance.test.ts +++ b/packages/core/tests/boringstack-final-acceptance.test.ts @@ -4,7 +4,7 @@ import type { IAcceptanceRunner, IAcceptanceSpec, IEntityAcceptance, -} from "../src/loop/acceptance/acceptance.types"; +} from "../src/loop/boringstack/acceptance/acceptance.types"; import { runFinalAcceptance } from "../src/loop/boringstack/build"; import type { Exec } from "../src/loop/boringstack/exec"; import type { IGreenfieldResult } from "../src/loop/greenfield/greenfield.types"; diff --git a/packages/core/tests/boringstack-gate-stages.test.ts b/packages/core/tests/boringstack-gate-stages.test.ts index 35dc72e8..f9b65a33 100644 --- a/packages/core/tests/boringstack-gate-stages.test.ts +++ b/packages/core/tests/boringstack-gate-stages.test.ts @@ -12,8 +12,8 @@ import { testIdStage, } from "../src/loop/boringstack/gate-stages"; import { scopeFor, featureOwnedGlobs } from "../src/loop/boringstack/build"; -import { testIdsFor } from "../src/loop/acceptance/acceptance-spec"; -import type { IEntityAcceptance } from "../src/loop/acceptance/acceptance.types"; +import { testIdsFor } from "../src/loop/boringstack/acceptance/acceptance-spec"; +import type { IEntityAcceptance } from "../src/loop/boringstack/acceptance/acceptance.types"; import type { Exec } from "../src/loop/boringstack/exec"; import type { SpecFetcher } from "../src/loop/boringstack/openapi-routes"; import type { IFeature } from "../src/loop/greenfield/greenfield.types"; diff --git a/packages/core/tests/boringstack-testid-contract.test.ts b/packages/core/tests/boringstack-testid-contract.test.ts index ba8a808d..85da90a0 100644 --- a/packages/core/tests/boringstack-testid-contract.test.ts +++ b/packages/core/tests/boringstack-testid-contract.test.ts @@ -13,8 +13,8 @@ import { import { planToAcceptanceSpec, testIdsFor, -} from "../src/loop/acceptance/acceptance-spec"; -import type { IEntityAcceptance } from "../src/loop/acceptance/acceptance.types"; +} from "../src/loop/boringstack/acceptance/acceptance-spec"; +import type { IEntityAcceptance } from "../src/loop/boringstack/acceptance/acceptance.types"; /** Build a minimal entity where the FIRST field is a parent FK (the distinguishing case the * Contact fixture can't exercise — its first field "name" is already non-FK). */ diff --git a/packages/core/tests/db-oracle.test.ts b/packages/core/tests/db-oracle.test.ts index bbfa62e5..838d13c0 100644 --- a/packages/core/tests/db-oracle.test.ts +++ b/packages/core/tests/db-oracle.test.ts @@ -5,7 +5,7 @@ import { schemaMismatchError, } from "../src/loop/boringstack/db-oracle"; import type { Exec } from "../src/loop/boringstack/exec"; -import type { IEntityAcceptance } from "../src/loop/acceptance/acceptance.types"; +import type { IEntityAcceptance } from "../src/loop/boringstack/acceptance/acceptance.types"; /** A minimal plan-derived entity. `fields` already includes any parent-FK fields (the * acceptance spec adds them), so this is exactly what the gate sees. */ diff --git a/packages/core/tests/gate-honesty.test.ts b/packages/core/tests/gate-honesty.test.ts index 973ed9d1..804d006f 100644 --- a/packages/core/tests/gate-honesty.test.ts +++ b/packages/core/tests/gate-honesty.test.ts @@ -3,7 +3,7 @@ import { composeBoringstackGate } from "../src/loop/boringstack/gate-stages"; import type { Exec, IExecResult } from "../src/loop/boringstack/exec"; import type { SpecFetcher } from "../src/loop/boringstack/openapi-routes"; import type { IFeature } from "../src/loop/greenfield/greenfield.types"; -import type { IEntityAcceptance } from "../src/loop/acceptance/acceptance.types"; +import type { IEntityAcceptance } from "../src/loop/boringstack/acceptance/acceptance.types"; import type { IProvider } from "../src/inference"; /** From 47edfe43e56173ecfa05faebc03514bcb2076623 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 08:00:38 +0200 Subject: [PATCH 52/53] =?UTF-8?q?fix(review):=20#63=20=E2=80=94=20stop=20t?= =?UTF-8?q?he=20panel=20pre-validate=20false-BLOCK=20+=20green-output=20ca?= =?UTF-8?q?che=20pollution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real bugs in the harness-review pre-validate path: 1. FLAKY 5s TIMEOUT → false-BLOCK: the `test` script ran `bun test packages` with bun's default 5000ms per-test cap. Slow hermetic tests (e.g. the eslint-spawning boundary test) flake under concurrency → validate exits red → the panel BLOCKs with "validate failed" on a clean diff. Fix = the prescribed `--timeout 30000` on the test script (never narrow the gate). 2. GREEN-OUTPUT CACHE POLLUTION: validateRunner substring-matched /error/iu on ALL output, so a PASSING validate ("0 errors", a test name like "…error handling…", a fixture string) produced a non-empty firstErrors. That summary feeds the verdict CACHE KEY, so identical green diffs got different keys run-to-run → the cache never hit and every panel re-ran the full 4-model review. Fix = extract the passed/errors mapping into a pure, exported `summarizeValidateOutput(text, code)` that returns an EMPTY summary on exit 0 (errors only matter when validate actually failed). Unit test locks both the empty-on-green behaviour (with a green output that contains "error") and the fail-path extraction (+20-line cap). --- package.json | 2 +- packages/core/src/cli/harness-review-mode.ts | 40 ++++++++++---- packages/core/tests/validate-summary.test.ts | 58 ++++++++++++++++++++ 3 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 packages/core/tests/validate-summary.test.ts diff --git a/package.json b/package.json index 4f74147e..083eb308 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint:fix": "eslint packages --fix", "format": "prettier --write \"packages/**/*.ts\"", "format:check": "prettier --check \"packages/**/*.ts\"", - "test": "bun test packages", + "test": "bun test packages --timeout 30000", "check:bun": "bun packages/core/scripts/check-bun-version.ts", "e2e": "python3 scripts/e2e-iterm-tui.py && python3 scripts/e2e-iterm-plan-mode.py", "e2e:pty": "python3 scripts/e2e-pty.py && python3 scripts/e2e-wizard-pty.py && python3 scripts/e2e-config-repl-pty.py && python3 scripts/e2e-help-menu-pty.py && python3 scripts/e2e-scaffold-command-pty.py && python3 scripts/e2e-editor-pty.py && python3 scripts/e2e-agents-pty.py && python3 scripts/e2e-spawn-agent-pty.py", diff --git a/packages/core/src/cli/harness-review-mode.ts b/packages/core/src/cli/harness-review-mode.ts index 1ba7a216..9f776a06 100644 --- a/packages/core/src/cli/harness-review-mode.ts +++ b/packages/core/src/cli/harness-review-mode.ts @@ -27,6 +27,7 @@ import { type IGitRunner, type IValidateRunner, } from "../reviewers/harness-review"; +import { type IValidateSummary } from "../reviewers/schema"; import { parseVerdict, type IVerdict } from "../reviewers/aggregate"; interface IArgs { @@ -185,23 +186,42 @@ async function gitRunner( return { stdout, code }; } -async function validateRunner(): Promise<{ - passed: boolean; - failCount: number; - firstErrors: string[]; -}> { +/** + * Map a `bun run validate` run (its combined stdout+stderr `text` and exit `code`) into the panel's + * IValidateSummary. Pure + exported so the passed/errors mapping is unit-tested. + * + * On PASS (exit 0) the summary is EMPTY errors — regardless of the word "error" appearing in green + * output ("0 errors", a test name like "…error handling…", a fixture string). The old code + * substring-matched `/error/iu` unconditionally, so a passing validate carried spurious diagnostics + * that (a) misled the block message and (b) polluted the verdict CACHE KEY (which includes the + * summary), varying run-to-run on identical green diffs → the cache never hit and every panel re-ran + * the full 4-model review. Only when validate actually FAILS (exit ≠ 0) do we extract error lines. + */ +export function summarizeValidateOutput( + text: string, + code: number +): IValidateSummary { + if (code === 0) { + return { passed: true, failCount: 0, firstErrors: [] }; + } + + const firstErrors = text + .split("\n") + .filter((l) => /error/iu.test(l)) + .slice(0, 20); + + return { passed: false, failCount: firstErrors.length, firstErrors }; +} + +async function validateRunner(): Promise { const proc = Bun.spawn(["bun", "run", "validate"], { stdout: "pipe", stderr: "pipe", }); const text = `${await new Response(proc.stdout).text()}\n${await new Response(proc.stderr).text()}`; const code = await proc.exited; - const firstErrors = text - .split("\n") - .filter((l) => /error/iu.test(l)) - .slice(0, 20); - return { passed: code === 0, failCount: firstErrors.length, firstErrors }; + return summarizeValidateOutput(text, code); } export const CACHE_DIR = join(".tsforge", "harness-review"); diff --git a/packages/core/tests/validate-summary.test.ts b/packages/core/tests/validate-summary.test.ts new file mode 100644 index 00000000..2bf4707f --- /dev/null +++ b/packages/core/tests/validate-summary.test.ts @@ -0,0 +1,58 @@ +import { test, expect, describe } from "bun:test"; +import { summarizeValidateOutput } from "../src/cli/harness-review-mode"; + +// #63: the panel's pre-review validate summary. `passed` is the validate EXIT CODE (so a fixture +// "error" string never false-BLOCKs). The bug this locks: on PASS the summary must be EMPTY — the +// old code substring-matched /error/iu on green output too, polluting the verdict cache key (which +// includes the summary) so identical green diffs got different keys and the panel never cached. +describe("summarizeValidateOutput (#63 panel pre-validate summary)", () => { + test("PASS (exit 0) yields an EMPTY summary even when green output contains the word 'error'", () => { + const greenOutput = [ + "$ bun test packages --timeout 30000", + " [PASS] surfaces error handling for the empty case", + "✓ 0 errors, 0 warnings", + "==== 7/7 — ALL PASS ====", + ].join("\n"); + + expect(summarizeValidateOutput(greenOutput, 0)).toEqual({ + passed: true, + failCount: 0, + firstErrors: [], + }); + }); + + test("two DIFFERENT green outputs both summarize identically (stable cache key)", () => { + const a = summarizeValidateOutput("ALL PASS\n0 errors", 0); + const b = summarizeValidateOutput( + "ALL PASS\nrun 'error-path' test\n0 errors", + 0 + ); + + expect(a).toEqual(b); + }); + + test("FAIL (exit != 0) extracts the error lines", () => { + const redOutput = [ + "$ bun run lint", + "src/foo.ts:3:1 error Unexpected any @typescript-eslint/no-explicit-any", + "clean line with no problem word", + 'error: script "lint" exited with code 1', + ].join("\n"); + const summary = summarizeValidateOutput(redOutput, 1); + + expect(summary.passed).toBe(false); + expect(summary.failCount).toBe(2); + expect(summary.firstErrors).toEqual([ + "src/foo.ts:3:1 error Unexpected any @typescript-eslint/no-explicit-any", + 'error: script "lint" exited with code 1', + ]); + }); + + test("FAIL caps firstErrors at 20 lines", () => { + const many = Array.from({ length: 25 }, (_, i) => `error ${i}`).join("\n"); + const summary = summarizeValidateOutput(many, 1); + + expect(summary.firstErrors).toHaveLength(20); + expect(summary.failCount).toBe(20); + }); +}); From 424b67fc82623993e62414348077d41dda673f0c Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 1 Aug 2026 08:04:28 +0200 Subject: [PATCH 53/53] revert: back out the #63 pre-validate changes (memory caught a panel-rejected gate relaxation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 47edfe43 added `bun test packages --timeout 30000` to the test script — which is EXACTLY the "tolerate the flake" fix the 4-model panel has rejected 4/4 THREE separate times (see memory panel-prevalidate-flaky-timeout): a global timeout bump weakens fail-fast for all files and is a gate relaxation. #63's false-BLOCK root cause is EXTERNAL CPU contention (a runaway build container), so its resolution is OPERATIONAL (build-teardown + re-run on a quiet box), not a code change — and the pre-review flake is already non-poisoning (a flake block is never cached). Also back out the validateRunner cache-key-hygiene change (empty summary on green): defensible on its own, but not worth re-litigating in the single most-scrutinized, repeatedly-rejected area of the codebase for modest value, especially since my motivation for touching it was a misdiagnosis. validateRunner + the test script are restored byte-for-byte to 9e42b93f. #63 stays open with the operational resolution noted; do NOT re-propose a timeout bump or retry. --- package.json | 2 +- packages/core/src/cli/harness-review-mode.ts | 40 ++++---------- packages/core/tests/validate-summary.test.ts | 58 -------------------- 3 files changed, 11 insertions(+), 89 deletions(-) delete mode 100644 packages/core/tests/validate-summary.test.ts diff --git a/package.json b/package.json index 083eb308..4f74147e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint:fix": "eslint packages --fix", "format": "prettier --write \"packages/**/*.ts\"", "format:check": "prettier --check \"packages/**/*.ts\"", - "test": "bun test packages --timeout 30000", + "test": "bun test packages", "check:bun": "bun packages/core/scripts/check-bun-version.ts", "e2e": "python3 scripts/e2e-iterm-tui.py && python3 scripts/e2e-iterm-plan-mode.py", "e2e:pty": "python3 scripts/e2e-pty.py && python3 scripts/e2e-wizard-pty.py && python3 scripts/e2e-config-repl-pty.py && python3 scripts/e2e-help-menu-pty.py && python3 scripts/e2e-scaffold-command-pty.py && python3 scripts/e2e-editor-pty.py && python3 scripts/e2e-agents-pty.py && python3 scripts/e2e-spawn-agent-pty.py", diff --git a/packages/core/src/cli/harness-review-mode.ts b/packages/core/src/cli/harness-review-mode.ts index 9f776a06..1ba7a216 100644 --- a/packages/core/src/cli/harness-review-mode.ts +++ b/packages/core/src/cli/harness-review-mode.ts @@ -27,7 +27,6 @@ import { type IGitRunner, type IValidateRunner, } from "../reviewers/harness-review"; -import { type IValidateSummary } from "../reviewers/schema"; import { parseVerdict, type IVerdict } from "../reviewers/aggregate"; interface IArgs { @@ -186,42 +185,23 @@ async function gitRunner( return { stdout, code }; } -/** - * Map a `bun run validate` run (its combined stdout+stderr `text` and exit `code`) into the panel's - * IValidateSummary. Pure + exported so the passed/errors mapping is unit-tested. - * - * On PASS (exit 0) the summary is EMPTY errors — regardless of the word "error" appearing in green - * output ("0 errors", a test name like "…error handling…", a fixture string). The old code - * substring-matched `/error/iu` unconditionally, so a passing validate carried spurious diagnostics - * that (a) misled the block message and (b) polluted the verdict CACHE KEY (which includes the - * summary), varying run-to-run on identical green diffs → the cache never hit and every panel re-ran - * the full 4-model review. Only when validate actually FAILS (exit ≠ 0) do we extract error lines. - */ -export function summarizeValidateOutput( - text: string, - code: number -): IValidateSummary { - if (code === 0) { - return { passed: true, failCount: 0, firstErrors: [] }; - } - - const firstErrors = text - .split("\n") - .filter((l) => /error/iu.test(l)) - .slice(0, 20); - - return { passed: false, failCount: firstErrors.length, firstErrors }; -} - -async function validateRunner(): Promise { +async function validateRunner(): Promise<{ + passed: boolean; + failCount: number; + firstErrors: string[]; +}> { const proc = Bun.spawn(["bun", "run", "validate"], { stdout: "pipe", stderr: "pipe", }); const text = `${await new Response(proc.stdout).text()}\n${await new Response(proc.stderr).text()}`; const code = await proc.exited; + const firstErrors = text + .split("\n") + .filter((l) => /error/iu.test(l)) + .slice(0, 20); - return summarizeValidateOutput(text, code); + return { passed: code === 0, failCount: firstErrors.length, firstErrors }; } export const CACHE_DIR = join(".tsforge", "harness-review"); diff --git a/packages/core/tests/validate-summary.test.ts b/packages/core/tests/validate-summary.test.ts deleted file mode 100644 index 2bf4707f..00000000 --- a/packages/core/tests/validate-summary.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { test, expect, describe } from "bun:test"; -import { summarizeValidateOutput } from "../src/cli/harness-review-mode"; - -// #63: the panel's pre-review validate summary. `passed` is the validate EXIT CODE (so a fixture -// "error" string never false-BLOCKs). The bug this locks: on PASS the summary must be EMPTY — the -// old code substring-matched /error/iu on green output too, polluting the verdict cache key (which -// includes the summary) so identical green diffs got different keys and the panel never cached. -describe("summarizeValidateOutput (#63 panel pre-validate summary)", () => { - test("PASS (exit 0) yields an EMPTY summary even when green output contains the word 'error'", () => { - const greenOutput = [ - "$ bun test packages --timeout 30000", - " [PASS] surfaces error handling for the empty case", - "✓ 0 errors, 0 warnings", - "==== 7/7 — ALL PASS ====", - ].join("\n"); - - expect(summarizeValidateOutput(greenOutput, 0)).toEqual({ - passed: true, - failCount: 0, - firstErrors: [], - }); - }); - - test("two DIFFERENT green outputs both summarize identically (stable cache key)", () => { - const a = summarizeValidateOutput("ALL PASS\n0 errors", 0); - const b = summarizeValidateOutput( - "ALL PASS\nrun 'error-path' test\n0 errors", - 0 - ); - - expect(a).toEqual(b); - }); - - test("FAIL (exit != 0) extracts the error lines", () => { - const redOutput = [ - "$ bun run lint", - "src/foo.ts:3:1 error Unexpected any @typescript-eslint/no-explicit-any", - "clean line with no problem word", - 'error: script "lint" exited with code 1', - ].join("\n"); - const summary = summarizeValidateOutput(redOutput, 1); - - expect(summary.passed).toBe(false); - expect(summary.failCount).toBe(2); - expect(summary.firstErrors).toEqual([ - "src/foo.ts:3:1 error Unexpected any @typescript-eslint/no-explicit-any", - 'error: script "lint" exited with code 1', - ]); - }); - - test("FAIL caps firstErrors at 20 lines", () => { - const many = Array.from({ length: 25 }, (_, i) => `error ${i}`).join("\n"); - const summary = summarizeValidateOutput(many, 1); - - expect(summary.firstErrors).toHaveLength(20); - expect(summary.failCount).toBe(20); - }); -});