From ae2406dade7fc92131a1b48acae1f840885bdc90 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Thu, 2 Jul 2026 20:58:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(ai-autopilot):=20web-app=20preset=20seam?= =?UTF-8?q?=20=E2=80=94=20framework=20detection=20+=20Vike/Next=20presets?= =?UTF-8?q?=20(#115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/ai-autopilot-framework-presets.md | 5 + packages/ai-autopilot/src/index.ts | 30 +++++- packages/ai-autopilot/src/personas/index.ts | 2 + packages/ai-autopilot/src/personas/library.ts | 38 +++++++- .../ai-autopilot/src/presets/define.test.ts | 22 +++++ packages/ai-autopilot/src/presets/define.ts | 32 +++++++ .../ai-autopilot/src/presets/detect.test.ts | 57 +++++++++++ packages/ai-autopilot/src/presets/detect.ts | 54 +++++++++++ packages/ai-autopilot/src/presets/index.ts | 30 ++++++ .../ai-autopilot/src/presets/library.test.ts | 60 ++++++++++++ packages/ai-autopilot/src/presets/library.ts | 96 +++++++++++++++++++ packages/ai-autopilot/src/presets/types.ts | 70 ++++++++++++++ 12 files changed, 492 insertions(+), 4 deletions(-) create mode 100644 .changeset/ai-autopilot-framework-presets.md create mode 100644 packages/ai-autopilot/src/presets/define.test.ts create mode 100644 packages/ai-autopilot/src/presets/define.ts create mode 100644 packages/ai-autopilot/src/presets/detect.test.ts create mode 100644 packages/ai-autopilot/src/presets/detect.ts create mode 100644 packages/ai-autopilot/src/presets/index.ts create mode 100644 packages/ai-autopilot/src/presets/library.test.ts create mode 100644 packages/ai-autopilot/src/presets/library.ts create mode 100644 packages/ai-autopilot/src/presets/types.ts diff --git a/.changeset/ai-autopilot-framework-presets.md b/.changeset/ai-autopilot-framework-presets.md new file mode 100644 index 0000000..5ae25f9 --- /dev/null +++ b/.changeset/ai-autopilot-framework-presets.md @@ -0,0 +1,5 @@ +--- +"@gemstack/ai-autopilot": minor +--- + +Add the web-app preset seam: framework-specific knowledge selected by detecting the app's framework, on top of the agnostic core. A `Preset` bundles a framework's personas with the signals that identify it; `detectFramework` scores a project's dependencies + files (deps weigh more than files) and `PresetRegistry.select` picks the preset (falling back to the flagship when nothing matches). Ships two built-ins — `vikePreset` (flagship) and `nextPreset` — plus a new `nextPageBuilder` persona (App Router + React Server Components). `presetPersonas(preset)` returns the framework page builder followed by the shared, framework-neutral personas (`sharedPersonas`: the universal-orm modeler + intent-UI designer), so only the page builder changes between frameworks while the rest of the stack stays put and prompts stay neutral. One shared core; a new framework is a new `Preset`, not a runtime fork. Closes #115. diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index f05c7c7..1e49a23 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -13,12 +13,13 @@ * - {@link agentSynthesizer} / {@link defaultSynthesize} — combine results * * Personas add the stack-aware knowledge layer: reusable roles that know the - * GemStack stack (Vike + universal-orm), materialized into worker agents. + * GemStack stack (Vike/Next + universal-orm), materialized into worker agents. * * - {@link definePersona} — define a stack-aware role * - {@link personaAgent} / {@link personaWorkers} — materialize personas for a run * - {@link personaRoster} — describe personas to a planner * - {@link stackPersonas} — the built-in Vike + universal-orm personas + * - {@link sharedPersonas} — the framework-neutral core (data layer + intent UI) * * The runner is the pluggable execution seam: a workspace (filesystem + shell + * optional preview) where autopilot builds and runs an app. Shaped after Flue's @@ -82,6 +83,14 @@ * - {@link detectMaterialChange} — the deterministic refresh trigger * - {@link agentOverview} / {@link overviewLoopPrompt} — regenerate with an agent, * and wire the maintainer into the loop + * + * Presets are the web-app layer: framework-specific personas selected by detecting + * the app's framework (Vike flagship, Next.js second), on top of the agnostic core. + * + * - {@link PresetRegistry} — register presets, {@link PresetRegistry.select} one + * - {@link detectFramework} — score a project's deps/files against presets + * - {@link vikePreset} / {@link nextPreset} — the built-ins + * - {@link presetPersonas} — a preset's personas + the shared neutral ones */ export { Supervisor } from './supervisor.js' export { agentPlanner, type AgentPlannerOptions } from './planner.js' @@ -95,8 +104,10 @@ export { personaWorkers, personaRoster, vikePageBuilder, + nextPageBuilder, universalOrmModeler, uiIntentDesigner, + sharedPersonas, stackPersonas, type Persona, type PersonaSpec, @@ -260,6 +271,23 @@ export { type OverviewRefresh, type OverviewEvent, } from './overview/index.js' +export { + definePreset, + PresetError, + detectFramework, + vikePreset, + nextPreset, + builtinPresets, + presetPersonas, + PresetRegistry, + builtinPresetRegistry, + type Preset, + type PresetSpec, + type PresetSignals, + type FrameworkSignals, + type PresetScore, + type FrameworkDetection, +} from './presets/index.js' export type { Subtask, PlannedSubtask, diff --git a/packages/ai-autopilot/src/personas/index.ts b/packages/ai-autopilot/src/personas/index.ts index 8c6e310..ec968c9 100644 --- a/packages/ai-autopilot/src/personas/index.ts +++ b/packages/ai-autopilot/src/personas/index.ts @@ -18,8 +18,10 @@ export { } from './compose.js' export { vikePageBuilder, + nextPageBuilder, universalOrmModeler, uiIntentDesigner, + sharedPersonas, stackPersonas, } from './library.js' export type { Persona, PersonaSpec } from './types.js' diff --git a/packages/ai-autopilot/src/personas/library.ts b/packages/ai-autopilot/src/personas/library.ts index 1d49558..7d5a8c3 100644 --- a/packages/ai-autopilot/src/personas/library.ts +++ b/packages/ai-autopilot/src/personas/library.ts @@ -33,6 +33,29 @@ Keep pages thin: routing + layout + data wiring. Business logic and persistence belong to the data layer, not the page.`, }) +/** Builds pages, routes, and layouts with Next.js' App Router + React Server Components. */ +export const nextPageBuilder: Persona = definePersona({ + name: 'next-page-builder', + role: 'Builds Next.js pages, routes, and layouts using the App Router and Server Components', + appliesTo: ['next'], + systemPrompt: `You build UI on Next.js with the App Router (React Server Components by default). + +Conventions to follow: +- Routing is filesystem-based under \`app/\`. A route is a folder with a + \`page.tsx\`; the folder path is the URL. Use \`layout.tsx\` for shared shells, + \`loading.tsx\` / \`error.tsx\` for states, and \`route.ts\` for API/route handlers. +- Components are Server Components by default: fetch data directly in an async + server component, and never ship server-only code or secrets to the client. + Add \`'use client'\` only for the leaf that genuinely needs interactivity. +- Prefer server actions for mutations over hand-rolled API routes when the caller + is your own UI. Validate input at the edge either way. +- Keep pages thin: routing + layout + data wiring. Business logic and persistence + belong to the data layer, not the page. + +Do not reach for \`getServerSideProps\`/\`getStaticProps\` (that is the older Pages +Router); use the App Router data model.`, +}) + /** Defines schema, derives migrations, and writes queries on universal-orm. */ export const universalOrmModeler: Persona = definePersona({ name: 'universal-orm-modeler', @@ -88,9 +111,18 @@ Your output should read as a declaration of what the UI is, leaving how it looks to the decoupled implementation.`, }) -/** All built-in stack personas, in a stable order. */ -export const stackPersonas: readonly Persona[] = Object.freeze([ - vikePageBuilder, +/** + * The framework-neutral personas shared by every preset — the data layer and the + * intent-based UI guardrail apply the same whether the app is on Vike or Next. + * A preset adds its framework-specific page builder on top (see the presets seam). + */ +export const sharedPersonas: readonly Persona[] = Object.freeze([ universalOrmModeler, uiIntentDesigner, ]) + +/** All built-in stack personas (the Vike stack), in a stable order. */ +export const stackPersonas: readonly Persona[] = Object.freeze([ + vikePageBuilder, + ...sharedPersonas, +]) diff --git a/packages/ai-autopilot/src/presets/define.test.ts b/packages/ai-autopilot/src/presets/define.test.ts new file mode 100644 index 0000000..232d0f5 --- /dev/null +++ b/packages/ai-autopilot/src/presets/define.test.ts @@ -0,0 +1,22 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { definePreset, PresetError } from './define.js' + +describe('definePreset', () => { + it('validates and freezes a preset, defaulting personas/signals', () => { + const preset = definePreset({ name: 'astro', framework: 'Astro' }) + assert.equal(preset.name, 'astro') + assert.equal(preset.framework, 'Astro') + assert.deepEqual(preset.personas, []) + assert.deepEqual(preset.signals.dependencies, []) + assert.throws(() => { + ;(preset as { name: string }).name = 'x' + }) + }) + + it('rejects a missing/non-kebab name and a missing framework', () => { + assert.throws(() => definePreset({ name: '', framework: 'X' }), PresetError) + assert.throws(() => definePreset({ name: 'Not Kebab', framework: 'X' }), /kebab-case/) + assert.throws(() => definePreset({ name: 'ok', framework: '' }), /needs a framework/) + }) +}) diff --git a/packages/ai-autopilot/src/presets/define.ts b/packages/ai-autopilot/src/presets/define.ts new file mode 100644 index 0000000..b4274eb --- /dev/null +++ b/packages/ai-autopilot/src/presets/define.ts @@ -0,0 +1,32 @@ +import type { Preset, PresetSpec } from './types.js' + +/** Thrown when a `PresetSpec` is malformed. Fails fast at definition time. */ +export class PresetError extends Error { + constructor(message: string) { + super(`[ai-autopilot] ${message}`) + this.name = 'PresetError' + } +} + +/** + * Validate a {@link PresetSpec} and return a frozen {@link Preset}. Optional + * fields default to empty so callers never null-check them. + */ +export function definePreset(spec: PresetSpec): Preset { + const name = spec.name?.trim() + if (!name) throw new PresetError('preset name is required') + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { + throw new PresetError(`preset name must be kebab-case: ${JSON.stringify(spec.name)}`) + } + if (!spec.framework?.trim()) throw new PresetError(`preset "${name}" needs a framework name`) + + return Object.freeze({ + name, + framework: spec.framework.trim(), + personas: Object.freeze([...(spec.personas ?? [])]), + signals: Object.freeze({ + dependencies: Object.freeze([...(spec.signals?.dependencies ?? [])]), + files: Object.freeze([...(spec.signals?.files ?? [])]), + }), + }) +} diff --git a/packages/ai-autopilot/src/presets/detect.test.ts b/packages/ai-autopilot/src/presets/detect.test.ts new file mode 100644 index 0000000..bdc459f --- /dev/null +++ b/packages/ai-autopilot/src/presets/detect.test.ts @@ -0,0 +1,57 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { detectFramework } from './detect.js' +import { vikePreset, nextPreset, builtinPresets } from './library.js' + +const presets = builtinPresets() + +describe('detectFramework', () => { + it('detects Vike from its dependency', () => { + const d = detectFramework(presets, { dependencies: { 'vike-react': '1.0.0', react: '18' } }) + assert.equal(d.preset?.name, 'vike') + assert.equal(d.framework, 'Vike') + assert.ok(d.confidence >= 2) + assert.match(d.scores[0]!.reasons.join(), /vike-react/) + }) + + it('detects Next from a dep + a file, and outscores a bare dep match', () => { + const d = detectFramework(presets, { + dependencies: ['next'], + files: ['app/dashboard/page.tsx', 'next.config.mjs'], + }) + assert.equal(d.preset?.name, 'next') + // dep (2) + two file patterns (1 each) = 4 + assert.ok(d.confidence >= 3) + }) + + it('accepts dependencies as a bare list of names', () => { + const d = detectFramework(presets, { dependencies: ['vike'] }) + assert.equal(d.preset?.name, 'vike') + }) + + it('returns no preset and confidence 0 when nothing matches', () => { + const d = detectFramework(presets, { dependencies: ['express'], files: ['server.js'] }) + assert.equal(d.preset, undefined) + assert.equal(d.framework, undefined) + assert.equal(d.confidence, 0) + assert.equal(d.scores.length, presets.length) // every preset still scored (at 0) + }) + + it('picks the higher-scoring framework when signals overlap', () => { + // a repo mid-migration: has next as a dep but many Vike files + const d = detectFramework(presets, { + dependencies: ['next', 'vike'], + files: ['pages/index/+Page.tsx', '+config.ts'], + }) + // vike: dep(2) + 2 files(2) = 4 ; next: dep(2) = 2 + assert.equal(d.preset?.name, 'vike') + assert.equal(d.scores[0]?.preset, 'vike') + assert.equal(d.scores[1]?.preset, 'next') + }) + + it('weights a dependency above a file match', () => { + const depOnly = detectFramework([nextPreset], { dependencies: ['next'] }).confidence + const fileOnly = detectFramework([vikePreset], { files: ['x/+config.ts'] }).confidence + assert.ok(depOnly > fileOnly) + }) +}) diff --git a/packages/ai-autopilot/src/presets/detect.ts b/packages/ai-autopilot/src/presets/detect.ts new file mode 100644 index 0000000..3d19ff5 --- /dev/null +++ b/packages/ai-autopilot/src/presets/detect.ts @@ -0,0 +1,54 @@ +import type { FrameworkDetection, FrameworkSignals, Preset, PresetScore } from './types.js' + +/** Weight of a matched dependency vs a matched file — deps are the stronger signal. */ +const DEP_WEIGHT = 2 +const FILE_WEIGHT = 1 + +function depNames(deps: FrameworkSignals['dependencies']): Set { + if (!deps) return new Set() + return new Set(Array.isArray(deps) ? deps : Object.keys(deps)) +} + +/** + * Score each preset against a project's {@link FrameworkSignals} and return the + * best match. Deterministic: dependencies weigh more than files, and every + * preset's score is returned (highest first) so ties are inspectable. When no + * preset matches, `preset`/`framework` are undefined and `confidence` is 0 — the + * caller decides the fallback (usually the flagship preset). + */ +export function detectFramework( + presets: readonly Preset[], + signals: FrameworkSignals, +): FrameworkDetection { + const deps = depNames(signals.dependencies) + const files = signals.files ?? [] + + const scores: PresetScore[] = presets.map(preset => { + const reasons: string[] = [] + let score = 0 + + for (const dep of preset.signals.dependencies ?? []) { + if (deps.has(dep)) { + score += DEP_WEIGHT + reasons.push(`dependency "${dep}"`) + } + } + for (const pattern of preset.signals.files ?? []) { + if (files.some(f => pattern.test(f))) { + score += FILE_WEIGHT + reasons.push(`file matching ${pattern}`) + } + } + return { preset: preset.name, score, reasons } + }) + + scores.sort((a, b) => b.score - a.score) + const top = scores[0] + const winner = top && top.score > 0 ? presets.find(p => p.name === top.preset) : undefined + + return { + ...(winner ? { preset: winner, framework: winner.framework } : {}), + confidence: top?.score ?? 0, + scores, + } +} diff --git a/packages/ai-autopilot/src/presets/index.ts b/packages/ai-autopilot/src/presets/index.ts new file mode 100644 index 0000000..2dcca63 --- /dev/null +++ b/packages/ai-autopilot/src/presets/index.ts @@ -0,0 +1,30 @@ +/** + * The web-app preset seam (#115) — framework-specific knowledge (personas) picked + * by *detecting* the app's framework, on top of the agnostic core. Vike is the + * flagship; Next.js is the second. A new framework is a new {@link Preset}, not a + * runtime fork. + * + * - {@link definePreset} — define a framework preset + * - {@link vikePreset} / {@link nextPreset} — the built-ins + * - {@link detectFramework} — score a project's deps/files against presets + * - {@link PresetRegistry} — register presets and {@link PresetRegistry.select} one + * - {@link presetPersonas} — a preset's personas + the shared neutral ones + */ +export { definePreset, PresetError } from './define.js' +export { detectFramework } from './detect.js' +export { + vikePreset, + nextPreset, + builtinPresets, + presetPersonas, + PresetRegistry, + builtinPresetRegistry, +} from './library.js' +export type { + Preset, + PresetSpec, + PresetSignals, + FrameworkSignals, + PresetScore, + FrameworkDetection, +} from './types.js' diff --git a/packages/ai-autopilot/src/presets/library.test.ts b/packages/ai-autopilot/src/presets/library.test.ts new file mode 100644 index 0000000..b8ced9c --- /dev/null +++ b/packages/ai-autopilot/src/presets/library.test.ts @@ -0,0 +1,60 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + vikePreset, + nextPreset, + builtinPresets, + presetPersonas, + PresetRegistry, + builtinPresetRegistry, +} from './library.js' + +describe('built-in presets', () => { + it('ship Vike (flagship) and Next, each with its page builder', () => { + assert.deepEqual(builtinPresets().map(p => p.name), ['vike', 'next']) + assert.equal(vikePreset.personas[0]?.name, 'vike-page-builder') + assert.equal(nextPreset.personas[0]?.name, 'next-page-builder') + }) +}) + +describe('presetPersonas', () => { + it('is the preset page builder followed by the shared neutral personas', () => { + const names = presetPersonas(vikePreset).map(p => p.name) + assert.deepEqual(names, ['vike-page-builder', 'universal-orm-modeler', 'ui-intent-designer']) + }) + + it('swaps only the page builder between frameworks — the rest of the stack is shared', () => { + const vike = presetPersonas(vikePreset).map(p => p.name) + const next = presetPersonas(nextPreset).map(p => p.name) + assert.equal(vike[0], 'vike-page-builder') + assert.equal(next[0], 'next-page-builder') + assert.deepEqual(vike.slice(1), next.slice(1)) // identical shared core + }) +}) + +describe('PresetRegistry', () => { + it('selects the detected preset', () => { + const { preset, detection } = builtinPresetRegistry().select({ dependencies: ['next'] }) + assert.equal(preset.name, 'next') + assert.equal(detection.framework, 'Next.js') + }) + + it('falls back to the flagship (first-registered) preset when nothing matches', () => { + const { preset, detection } = builtinPresetRegistry().select({ dependencies: ['express'] }) + assert.equal(preset.name, 'vike') // flagship default + assert.equal(detection.preset, undefined) // ...but detection is honest that nothing matched + }) + + it('honors an explicit fallback', () => { + const { preset } = builtinPresetRegistry().select({ files: [] }, nextPreset) + assert.equal(preset.name, 'next') + }) + + it('get / all / add', () => { + const reg = new PresetRegistry([vikePreset]) + assert.equal(reg.get('vike')?.name, 'vike') + assert.equal(reg.get('next'), undefined) + reg.add(nextPreset) + assert.deepEqual(reg.all().map(p => p.name), ['vike', 'next']) + }) +}) diff --git a/packages/ai-autopilot/src/presets/library.ts b/packages/ai-autopilot/src/presets/library.ts new file mode 100644 index 0000000..1cb4a86 --- /dev/null +++ b/packages/ai-autopilot/src/presets/library.ts @@ -0,0 +1,96 @@ +import { vikePageBuilder, nextPageBuilder, sharedPersonas } from '../personas/library.js' +import type { Persona } from '../personas/types.js' +import { definePreset } from './define.js' +import { detectFramework } from './detect.js' +import type { FrameworkDetection, FrameworkSignals, Preset } from './types.js' + +/** + * The flagship preset: Vike (Vite + SSR), renderer-agnostic. Its page builder + * plus the shared neutral personas make up the Vike stack. + */ +export const vikePreset: Preset = definePreset({ + name: 'vike', + framework: 'Vike', + personas: [vikePageBuilder], + signals: { + dependencies: ['vike', 'vike-react', 'vike-vue', 'vike-solid'], + files: [/(^|\/)\+Page(\.[\w-]+)?\.[jt]sx?$/, /(^|\/)\+config\.[jt]s$/], + }, +}) + +/** The second preset: Next.js (App Router + React Server Components). */ +export const nextPreset: Preset = definePreset({ + name: 'next', + framework: 'Next.js', + personas: [nextPageBuilder], + signals: { + dependencies: ['next'], + files: [/(^|\/)next\.config\.[cm]?[jt]s$/, /(^|\/)app\/.*\/page\.[jt]sx?$/, /(^|\/)app\/layout\.[jt]sx?$/], + }, +}) + +/** The built-in presets, in a stable order (flagship first). */ +export function builtinPresets(): Preset[] { + return [vikePreset, nextPreset] +} + +/** + * The full worker roster for a preset: its framework-specific personas followed + * by the shared, framework-neutral ones (data layer + intent UI). This is what + * you hand to `personaWorkers` / a planner roster, so only the page builder + * changes between frameworks while the rest of the stack stays put. + */ +export function presetPersonas(preset: Preset, shared: readonly Persona[] = sharedPersonas): Persona[] { + return [...preset.personas, ...shared] +} + +/** + * A set of {@link Preset}s with detection. Register the built-ins (or your own), + * then {@link select} the preset for a project by its {@link FrameworkSignals}. + * One shared core; the registry only decides which framework knowledge to layer. + */ +export class PresetRegistry { + private readonly byName = new Map() + + constructor(presets: readonly Preset[] = builtinPresets()) { + for (const p of presets) this.byName.set(p.name, p) + } + + /** The preset with this name, or `undefined`. */ + get(name: string): Preset | undefined { + return this.byName.get(name) + } + + /** All presets, in registration order. */ + all(): Preset[] { + return [...this.byName.values()] + } + + /** Add or replace a preset (e.g. a project's own framework). Returns `this`. */ + add(preset: Preset): this { + this.byName.set(preset.name, preset) + return this + } + + /** Detect the framework for a project from its dependencies / files. */ + detect(signals: FrameworkSignals): FrameworkDetection { + return detectFramework(this.all(), signals) + } + + /** + * Select the preset for a project: the detected one, or `fallback` (default the + * flagship, first-registered preset) when nothing matched — so a run always has + * a preset even on an empty or unrecognized project. + */ + select(signals: FrameworkSignals, fallback?: Preset): { preset: Preset; detection: FrameworkDetection } { + const detection = this.detect(signals) + const preset = detection.preset ?? fallback ?? this.all()[0] + if (!preset) throw new Error('[ai-autopilot] PresetRegistry.select: no presets registered') + return { preset, detection } + } +} + +/** The built-in presets as a ready-to-use {@link PresetRegistry}. */ +export function builtinPresetRegistry(): PresetRegistry { + return new PresetRegistry(builtinPresets()) +} diff --git a/packages/ai-autopilot/src/presets/types.ts b/packages/ai-autopilot/src/presets/types.ts new file mode 100644 index 0000000..2c49a25 --- /dev/null +++ b/packages/ai-autopilot/src/presets/types.ts @@ -0,0 +1,70 @@ +import type { Persona } from '../personas/types.js' + +/** + * The web-app preset seam (#115). The engine (loop + state layer) is + * framework-agnostic; the framework-specific knowledge lives here, at the + * persona layer, and is selected by *detecting* the app's framework rather than + * forking the runtime. Vike is the flagship preset; Next.js is the second. New + * frameworks are a new {@link Preset}, not a change to the core. + * + * A preset is data: a name, its framework-specific personas, and the signals that + * identify it in a project. {@link detectFramework} scores the signals; the + * shared, framework-neutral personas (data layer, intent UI) are added on top by + * {@link presetPersonas}, so a preset only carries what is genuinely per-framework. + */ + +/** How to recognize a framework in a project. */ +export interface PresetSignals { + /** Dependency names whose presence indicates this framework (e.g. `vike`, `next`). */ + dependencies?: readonly string[] + /** File-path patterns that indicate it (e.g. `next.config.*`, a `+Page` file). */ + files?: readonly RegExp[] +} + +/** A framework preset: framework-specific personas + the signals that detect it. */ +export interface Preset { + /** Stable id, kebab-case (e.g. `vike`, `next`). */ + readonly name: string + /** Human framework name (e.g. `Vike`, `Next.js`). */ + readonly framework: string + /** The framework-specific personas (the neutral shared ones are added separately). */ + readonly personas: readonly Persona[] + /** How this preset is detected in a project. */ + readonly signals: PresetSignals +} + +/** The author-facing shape for {@link definePreset}; personas/signals default to empty. */ +export interface PresetSpec { + name: string + framework: string + personas?: readonly Persona[] + signals?: PresetSignals +} + +/** What {@link detectFramework} inspects: a project's dependencies and/or file list. */ +export interface FrameworkSignals { + /** Dependencies, as a `name -> version` map or a bare list of names. */ + dependencies?: Record | readonly string[] + /** Paths present in the project (any depth). */ + files?: readonly string[] +} + +/** One preset's score against the project signals. */ +export interface PresetScore { + preset: string + score: number + /** The concrete signals that matched. */ + reasons: string[] +} + +/** The outcome of detection: the best preset (if any) and every score. */ +export interface FrameworkDetection { + /** The highest-scoring preset, when one matched at all. */ + preset?: Preset + /** Its framework name, for narration. */ + framework?: string + /** The winning score (0 when nothing matched). */ + confidence: number + /** Every preset's score, highest first — for tie inspection / debugging. */ + scores: PresetScore[] +}