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. 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..c6c27389 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-app-own-layout-design.md @@ -0,0 +1,86 @@ +# Scaffold ANY app in its OWN layout (BoringStack demo dashboard becomes disposable) — Design Spec v3 + +**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 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:** 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 nav — the demo shell/routes stay as Settings + +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 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`). + +### 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 (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 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. + +### `IShellDescriptor` (keyed by shellLayout, plan-level) +```ts +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_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_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 +``` +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. + +## Stages (each additive, landable, no dead code, no default-flip) + +### 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 2 — `app-sidebar` end-to-end behind the flag (additive) +`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. + +### 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); 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 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. +- **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. diff --git a/eslint.config.js b/eslint.config.js index b879d985..ac59598d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -16,7 +16,17 @@ 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 `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_*/**", + ], }, { files: ["packages/**/*.ts"], @@ -219,5 +229,96 @@ 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`. + * 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/**"], + 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/**).", + }, + ], + }, + ], + // 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. 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", + { + selector: "TSEnumDeclaration", + 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. (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"], 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/**).", + }, + { + // The templated form of either loader (`import(`../boringstack/x`)` / + // `createRequire(u)(`../boringstack/x`)`). + selector: + ':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/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/agent/agent.constants.ts b/packages/core/src/agent/agent.constants.ts index 27c42e17..781c2641 100644 --- a/packages/core/src/agent/agent.constants.ts +++ b/packages/core/src/agent/agent.constants.ts @@ -345,44 +345,63 @@ export const PACKAGE_INFO_TOOL = { }, }; -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.", - 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", - ], +/** + * 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 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 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/cli/repl.ts b/packages/core/src/cli/repl.ts index 79b632bc..655b2efc 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -43,10 +43,12 @@ import { type ILoopEvent, } from "../loop"; import { runPlanning } from "../loop/planning/run-planning"; +import type { IPlanConstraints } from "../loop/planning/plan-types"; 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 +160,89 @@ 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 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, stack: IStackAdapter) => Promise +): Promise { + const stack = await resolveStackAdapter(dir, adapters); + + if (stack === null) { + return null; + } + + // 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; +} + +/** + * 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` + ); + }); +} + +/** + * 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, stack: IStackAdapter) => 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 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,13 +253,12 @@ async function runGreenfieldPlanning( const result = await runPlanning(dir, { planner: plannerProvider, - // We only reach here when looksLikeBoringstack is true, so the BoringStack + // 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: boringstackPlanConstraints((dropped) => { - echo( - `▸ dropped auth slice(s) BoringStack already provides: ${dropped.join(", ")}\n` - ); - }), + constraints: greenfieldConstraints(stack, echo), describe: async () => { await Promise.resolve(); @@ -875,23 +948,27 @@ 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); - - 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); + // 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, s) => (await loadApprovedPlan(d, s.planSchema)) !== null, + (stack) => + runGreenfieldPlanning( + args.dir, + line, + echo, + rl, + activeModelEntry, + stack + ), + () => runSend(line) + ); }; // Placeholder declarations; defined after runLine / editorControl are available. 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 87% rename from packages/core/src/loop/acceptance/acceptance-spec.ts rename to packages/core/src/loop/boringstack/acceptance/acceptance-spec.ts index 0c606958..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, IEntitySpec } from "../planning/plan-types"; +import type { + IProductPlan, + ISlice, + IEntitySpec, +} from "../../planning/plan-types"; import type { IAcceptanceSpec, IEntityAcceptance, @@ -8,6 +12,21 @@ import type { ITestIds, } from "./acceptance.types"; +/** The UI fields acceptance generation projects from a slice's `ui` — nav label, shown fields, and + * 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[]; + readonly screens: readonly string[]; +} + function camel(s: string): string { if (s.length === 0) { return s; @@ -245,9 +264,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 +281,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 +387,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-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 89% rename from packages/core/src/loop/acceptance/acceptance.types.ts rename to packages/core/src/loop/boringstack/acceptance/acceptance.types.ts index 7757447b..8a19b179 100644 --- a/packages/core/src/loop/acceptance/acceptance.types.ts +++ b/packages/core/src/loop/boringstack/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/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-config.ts b/packages/core/src/loop/boringstack/build-config.ts index 457078a3..380956df 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/boringstack/build.ts b/packages/core/src/loop/boringstack/build.ts index 02404a4c..7cd64074 100644 --- a/packages/core/src/loop/boringstack/build.ts +++ b/packages/core/src/loop/boringstack/build.ts @@ -23,8 +23,14 @@ 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 { planToAcceptanceSpec } from "../acceptance/acceptance-spec"; +import type { ISlice } from "../planning/plan-types"; +import { + boringstackPlanSchema, + boringstackUiFields, + type BoringstackProductPlan, + type IUiIntent, +} from "./plan-extension"; +import { planToAcceptanceSpec } from "./acceptance/acceptance-spec"; import { buildTestIdGuide } from "./acceptance/testid-contract"; import type { IEntityAcceptance, @@ -32,11 +38,15 @@ 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"; +/** BoringStack builds a concrete web plan — the generic spine specialized to IUiIntent. */ +type BsPlan = BoringstackProductPlan; +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 +606,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 +673,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 +865,7 @@ export async function runFinalAcceptance( cwd: string, exec: Exec, acceptanceRunner: IAcceptanceRunner | undefined, - approved: IProductPlan, + approved: BsPlan, e2eAcceptanceDisabled: boolean, onEvent?: Reporter ): Promise { @@ -909,7 +922,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 +972,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 +988,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 +1030,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 +1204,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 +1214,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/conventions.ts b/packages/core/src/loop/boringstack/conventions.ts similarity index 97% rename from packages/core/src/loop/conventions.ts rename to packages/core/src/loop/boringstack/conventions.ts index 1f3ed315..21b26e65 100644 --- a/packages/core/src/loop/conventions.ts +++ b/packages/core/src/loop/boringstack/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,18 @@ export function unseenGuidesForErrors( return out; } + +/** + * 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`). This module lives under + * `loop/boringstack/`, so no core file outside the adapter imports it. + */ +export const boringstackConventionProvider: IConventionProvider = { + buildGuides: buildConventionGuides, + unseenForErrors: unseenGuidesForErrors, + guide: (topic) => (isConventionTopic(topic) ? conventionGuide(topic) : null), + topics: conventionTopics, +}; 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/src/loop/boringstack/plan-extension.ts b/packages/core/src/loop/boringstack/plan-extension.ts new file mode 100644 index 00000000..7d0fc387 --- /dev/null +++ b/packages/core/src/loop/boringstack/plan-extension.ts @@ -0,0 +1,253 @@ +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)}`; + +/** + * 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 + * 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, + validateUi: isBoringstackUiIntent, + 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`); 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) => boringstackAtMostOneHome(plan.slices.map((s) => s.ui)), +}; + +/** + * 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/planning/boringstack-planning.ts b/packages/core/src/loop/boringstack/planning.ts similarity index 83% rename from packages/core/src/loop/planning/boringstack-planning.ts rename to packages/core/src/loop/boringstack/planning.ts index 64dff8d1..f5cf3e9b 100644 --- a/packages/core/src/loop/planning/boringstack-planning.ts +++ b/packages/core/src/loop/boringstack/planning.ts @@ -1,7 +1,9 @@ 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"; +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 @@ -80,3 +82,16 @@ 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, + planSchema: boringstackPlanSchemaErased, +}; 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/conventions-provider.ts b/packages/core/src/loop/conventions-provider.ts new file mode 100644 index 00000000..d97009f9 --- /dev/null +++ b/packages/core/src/loop/conventions-provider.ts @@ -0,0 +1,24 @@ +/** + * 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 injected into the system prompt (was the direct + * `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[]; +} diff --git a/packages/core/src/loop/planning/plan-store.ts b/packages/core/src/loop/planning/plan-store.ts index c46f44d6..809f69e5 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,18 +195,20 @@ export function isProductPlan(value: unknown): value is IProductPlan { return false; } - if (!value.slices.every(isSlice)) { - 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[] = []; - // 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; + for (const s of value.slices) { + if (!isSlice(s, validateUi)) { + return false; + } - if (homeCount > 1) { - return false; + slices.push(s); + } + + if (extraCheck !== undefined) { + return extraCheck({ product: value.product, slices }); } return true; @@ -316,9 +269,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 +297,7 @@ export function parsePlan( return null; } - if (!isProductPlan(plan)) { + if (!isProductPlan(plan, schema.validateUi, schema.extraCheck)) { return null; } @@ -356,9 +310,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 +326,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 +347,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..a80e2f10 100644 --- a/packages/core/src/loop/planning/plan-types.ts +++ b/packages/core/src/loop/planning/plan-types.ts @@ -10,61 +10,49 @@ 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 (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. + */ +export interface IPlanSchema { + /** 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; + /** 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. Re-checked + * after reserved-slice stripping, so a transform can't leave the invariant false. */ + 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..fc22a69b 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; } @@ -172,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) @@ -187,7 +121,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 +136,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..6f591c92 100644 --- a/packages/core/src/loop/planning/run-planning.ts +++ b/packages/core/src/loop/planning/run-planning.ts @@ -1,17 +1,21 @@ 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 (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 * 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 +24,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 +36,7 @@ export async function runPlanning( const plan = await proposePlan( { planner: deps.planner }, currentInput, + deps.schema, deps.constraints ?? {} ); 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..88a76264 --- /dev/null +++ b/packages/core/src/loop/planning/stack-adapter.ts @@ -0,0 +1,56 @@ +import type { IPlanConstraints, IPlanSchema } 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. `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; + /** + * 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; +} + +/** + * 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/src/loop/session.ts b/packages/core/src/loop/session.ts index 554b8125..b46805b3 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,12 @@ 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`), 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 * via `setGate`). The tool runs the SAME full evaluation `settleGate` does @@ -590,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) @@ -605,7 +620,7 @@ function systemPrompt( ? buildDriveToGreenSystem( conventions, cfg.offerCheck === true, - cfg.pullConventions === true + conventionsOffered(cfg) ) : buildChatSystem(conventions); @@ -628,8 +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 ? `${buildConventionGuides()}\n\n` : ""; + const conv = conventionsOffered(cfg) + ? `${cfg.conventions?.buildGuides() ?? ""}\n\n` + : ""; const contract = taskContract(cfg.files ?? [], cfg.accept); @@ -792,12 +808,25 @@ export class Session { const offerCheck = cfg.offerCheck === true && cfg.executionMode === "drive-to-green"; + // 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 offerConventions = conventionsOffered(cfg); + const conventionTopics = + offerConventions && cfg.conventions !== undefined + ? cfg.conventions.topics() + : []; + this.tools = toolsFor( false, {}, - cfg.pullConventions === true, + offerConventions, offerCheck, - cfg.interactive === true + cfg.interactive === true, + conventionTopics ); this.ctx = ctx; @@ -943,6 +972,14 @@ 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. 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/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..735c58e4 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` 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[]; /** 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..91ebc3eb 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, @@ -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) { @@ -280,6 +281,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 +2177,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/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/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 530fcdfb..0147a8ef 100644 --- a/packages/core/tests/acceptance-spec.test.ts +++ b/packages/core/tests/acceptance-spec.test.ts @@ -4,10 +4,25 @@ 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 +// 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/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-config.test.ts b/packages/core/tests/boringstack-build-config.test.ts index 1fcd809b..be4ca14b 100644 --- a/packages/core/tests/boringstack-build-config.test.ts +++ b/packages/core/tests/boringstack-build-config.test.ts @@ -30,6 +30,14 @@ 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(); + // 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/boringstack-build.test.ts b/packages/core/tests/boringstack-build.test.ts index ddc6bf35..45ac6e92 100644 --- a/packages/core/tests/boringstack-build.test.ts +++ b/packages/core/tests/boringstack-build.test.ts @@ -26,12 +26,13 @@ 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"; 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-conventions.test.ts b/packages/core/tests/boringstack-conventions.test.ts index 32242c7a..bea0b717 100644 --- a/packages/core/tests/boringstack-conventions.test.ts +++ b/packages/core/tests/boringstack-conventions.test.ts @@ -6,8 +6,8 @@ import { isConventionTopic, topicForRule, unseenGuidesForErrors, -} from "../src/loop/conventions"; -import { PULL_CONVENTIONS_TOOL } from "../src/agent/agent.constants"; + boringstackConventionProvider, +} 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"; @@ -94,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)", () => { @@ -306,7 +302,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( @@ -401,4 +399,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/boringstack-e2e-generator.test.ts b/packages/core/tests/boringstack-e2e-generator.test.ts index 246c75e4..0bf1f7d1 100644 --- a/packages/core/tests/boringstack-e2e-generator.test.ts +++ b/packages/core/tests/boringstack-e2e-generator.test.ts @@ -5,13 +5,17 @@ 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, + 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-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 d927e189..71c9361e 100644 --- a/packages/core/tests/boringstack-final-acceptance.test.ts +++ b/packages/core/tests/boringstack-final-acceptance.test.ts @@ -4,11 +4,12 @@ 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"; 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-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-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/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..85da90a0 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, @@ -9,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). */ @@ -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/convention-index.test.ts b/packages/core/tests/convention-index.test.ts index 3437aa62..9352114e 100644 --- a/packages/core/tests/convention-index.test.ts +++ b/packages/core/tests/convention-index.test.ts @@ -7,10 +7,42 @@ import { conventionGuide, conventionTopics, topicForRule, -} from "../src/loop/conventions"; -import { PULL_CONVENTIONS_TOOL } from "../src/agent/agent.constants"; + boringstackConventionProvider, +} 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"; +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. */ +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 => ({ + buildGuides: () => guides, + unseenForErrors: () => [], + guide: () => null, + topics: () => [], +}); // 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 @@ -67,6 +99,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"); @@ -91,23 +124,380 @@ test("the convention guides are in the system prompt with pullConventions, absen } }); -// 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; +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-")); - expect([...enumTopics].sort()).toEqual([...conventionTopics()].sort()); + 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 }); + } }); -// 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(); +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: fakeConventions("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: fakeConventions("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 }); + } +}); + +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 }); + } +}); + +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 }); + } +}); + +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("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[]; system: string } = { + names: [], + system: "", + }; + + const provider: IProvider = { + 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) => + 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"); + + // 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 }); + } +}); + +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 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", @@ -116,14 +506,58 @@ 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); } }); +// 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. diff --git a/packages/core/tests/conventions-provider.test.ts b/packages/core/tests/conventions-provider.test.ts new file mode 100644 index 00000000..96ac2352 --- /dev/null +++ b/packages/core/tests/conventions-provider.test.ts @@ -0,0 +1,39 @@ +import { test, expect } from "bun:test"; +import type { IConventionProvider } from "../src/loop/conventions-provider"; +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 +// (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"); +}); + +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/core-adapter-boundary.test.ts b/packages/core/tests/core-adapter-boundary.test.ts new file mode 100644 index 00000000..e9f80960 --- /dev/null +++ b/packages/core/tests/core-adapter-boundary.test.ts @@ -0,0 +1,224 @@ +import { test, expect } from "bun:test"; +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 +// (`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 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"); +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 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 { + readonly filePath: string; + 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 + * 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 = ( + 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. + 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. + 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(dir, name), source); + } + } + + 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 + // 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()} | stdout: ${stdout}` + ); + } + + const byName = new Map(); + + for (const r of parsed) { + // basename() handles both / and \ separators, so keys match fixture basenames off Unix too. + byName.set( + basename(r.filePath), + r.messages.map((m) => m.ruleId) + ); + } + + return byName; + } finally { + rmSync(CORE_DIR, { recursive: true, force: true }); + rmSync(ADAPTER_DIR, { recursive: true, force: true }); + } +}; + +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); + // 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 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); + expect(results.get("leak-dynamic-template.ts")).toContain(SYNTAX_RULE); + 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, 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. + 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 / + // 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); + +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); 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/drive-to-green-check-prompt.test.ts b/packages/core/tests/drive-to-green-check-prompt.test.ts index a5292210..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,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/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 @@ -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, }); 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"; /** 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..9e9ac991 --- /dev/null +++ b/packages/core/tests/generic-plan-seam.test.ts @@ -0,0 +1,94 @@ +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, +}); +// 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 + 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); + }); + + 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/plan-extension.test.ts b/packages/core/tests/plan-extension.test.ts new file mode 100644 index 00000000..ac8745df --- /dev/null +++ b/packages/core/tests/plan-extension.test.ts @@ -0,0 +1,154 @@ +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 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), invalidHomeSlice], + }) + ).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/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/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"], diff --git a/packages/core/tests/propose-plan.test.ts b/packages/core/tests/propose-plan.test.ts index 02759edf..86af5f02 100644 --- a/packages/core/tests/propose-plan.test.ts +++ b/packages/core/tests/propose-plan.test.ts @@ -3,17 +3,41 @@ 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/planning/boringstack-planning"; -import type { IProductPlan, ISlice } from "../src/loop/planning/plan-types"; +} from "../src/loop/boringstack/planning"; +import { + boringstackPlanSchema, + isBoringstackUiIntent, + PLANNER_EXAMPLE, + PLANNER_SYSTEM, + type IUiIntent, +} from "../src/loop/boringstack/plan-extension"; +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 +// 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 +57,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 +72,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 +85,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 +107,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 +126,7 @@ test("validation failure on both attempts yields null", async () => { }, }; - const plan = await proposePlan( + const plan = await runPropose( { planner: failingPlanner }, { description: "test" } ); @@ -128,7 +152,7 @@ test("proposePlan includes mockup refs in user message", async () => { }, }; - await proposePlan( + await runPropose( { planner: capturingPlanner }, { description: "test app", @@ -144,19 +168,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 +204,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 +259,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 +277,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 +286,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 +296,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 +315,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 +337,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 +365,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,12 +390,85 @@ 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"]); }); +// ── 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()); @@ -385,8 +482,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 +499,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/pull-conventions.test.ts b/packages/core/tests/pull-conventions.test.ts index 01d83ee0..964ce484 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/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. +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"); + }); }); 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..689992a4 --- /dev/null +++ b/packages/core/tests/repl-greenfield-stack.test.ts @@ -0,0 +1,431 @@ +import { test, expect, describe } from "bun:test"; +import { join } from "node:path"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +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 + * 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, + }), + planSchema: { system: "", validateUi: (_v: unknown): _v is unknown => true }, +}); + +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("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", + [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"); + }); +}); + +// 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)", () => { + /** 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. 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 spy = (): ISpy => { + const s: ISpy = { + greenfield: 0, + send: 0, + stack: 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.onGreenfield, + s.onSend + ); + + expect(s.greenfield).toBe(1); + expect(s.send).toBe(0); + expect(s.stack).toBe(detected); + }); + + test("an undetected project runs onSend EXACTLY once, never onGreenfield", async () => { + const s = spy(); + + await greenfieldOrSend( + "/dir", + [stub("a", false)], + noApprovedPlan, + s.onGreenfield, + s.onSend + ); + + expect(s.send).toBe(1); + expect(s.greenfield).toBe(0); + }); + + test("a detected but ALREADY-planned project runs onSend EXACTLY once, never onGreenfield", async () => { + const s = spy(); + + await greenfieldOrSend( + "/dir", + [stub("boringstack", true)], + hasApprovedPlan, + s.onGreenfield, + s.onSend + ); + + expect(s.send).toBe(1); + expect(s.greenfield).toBe(0); + }); +}); + +// 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. 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: all three args are pinned literal, so a sending hasPlan / block-body onGreenfield / +// send chained onto planning is a DIFFERENT node → no match; +// - 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. 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, + "..", + "..", + "..", + "node_modules", + ".bin", + "ast-grep" +); +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, 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 + * 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", + "-p", + pattern, + "-l", + "ts", + "--json", + file, + ]); + + 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}`); + } + + return parsed.length; +}; + +/** 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 { + const file = join(dir, "decoy.ts"); + + await writeFile(file, source); + + 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); + }); + + // 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, 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); + }); + + // 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, 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, 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); + }); + + 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 countOn(wrapArrow(`${bypass};`))).toBe(0); + }); + + // 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 () => { + 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 () => { + expect( + await countOn( + wrapArrow(`if (false) { ${CORRECT_CALL}; }\n await runSend(line);`) + ) + ).toBe(0); + }); + + 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 () => { + expect( + await countOn( + wrapArrow(`try { ${CORRECT_CALL}; } finally { await runSend(line); }`) + ) + ).toBe(0); + }); + + 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); + }); + + // 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. 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};`)) + ).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); + }); +}); 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). diff --git a/packages/core/tests/stack-adapter.test.ts b/packages/core/tests/stack-adapter.test.ts new file mode 100644 index 00000000..9cdf6452 --- /dev/null +++ b/packages/core/tests/stack-adapter.test.ts @@ -0,0 +1,98 @@ +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, +} 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 }), + planSchema: { system: "", validateUi: (_v: unknown): _v is unknown => true }, +}); + +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); + }); + + 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 }); + } + }); +});