@basaltkit/ai 1.4.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands.js +8 -2
- package/dist/context/project.d.ts +51 -0
- package/dist/context/project.js +180 -4
- package/dist/doctor/rules.d.ts +9 -0
- package/dist/doctor/rules.js +199 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/make/make.d.ts +2 -0
- package/dist/make/make.js +12 -2
- package/dist/plan/context.d.ts +2 -2
- package/dist/plan/context.js +9 -2
- package/dist/plan/knowledge.d.ts +1 -1
- package/dist/plan/knowledge.js +8 -2
- package/dist/plan/packages.d.ts +23 -0
- package/dist/plan/packages.js +39 -0
- package/dist/plan/plan.js +1 -0
- package/dist/plan/render.js +1 -1
- package/dist/plan/types.d.ts +3 -1
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.js +1 -0
- package/package.json +2 -2
package/dist/commands.js
CHANGED
|
@@ -6,7 +6,7 @@ import { applyFixEdits, fixableIds, planFix, renderFixes } from './doctor/fixes.
|
|
|
6
6
|
import { createProvider, providerEnvFromProcess } from './provider/factory.js';
|
|
7
7
|
import { createPlan } from './plan/plan.js';
|
|
8
8
|
import { renderPlan } from './plan/render.js';
|
|
9
|
-
import { runMake } from './make/make.js';
|
|
9
|
+
import { packageSteps, runMake } from './make/make.js';
|
|
10
10
|
import { renderMakeResult } from './make/render.js';
|
|
11
11
|
import { runPrismaPush } from './make/schema.js';
|
|
12
12
|
import { verifyProject } from './make/verify.js';
|
|
@@ -39,7 +39,7 @@ export function aiCommands(options = {}) {
|
|
|
39
39
|
io.log('');
|
|
40
40
|
if (args.length > 0) {
|
|
41
41
|
io.log(`You asked: "${args.join(' ')}"`);
|
|
42
|
-
io.log(
|
|
42
|
+
io.log(`Plan it with: basalt ai:plan "${args.join(' ')}"`);
|
|
43
43
|
io.log('');
|
|
44
44
|
}
|
|
45
45
|
io.log('Available now:');
|
|
@@ -182,6 +182,12 @@ export function aiCommands(options = {}) {
|
|
|
182
182
|
}
|
|
183
183
|
renderPlan(plan, io);
|
|
184
184
|
io.log('');
|
|
185
|
+
// A plan that only reuses packages has nothing to scaffold — ai:make does
|
|
186
|
+
// not install dependencies, so the package steps above are the whole job.
|
|
187
|
+
if (plan.entities.length === 0 && packageSteps(plan).length > 0) {
|
|
188
|
+
io.log('Nothing to scaffold — this plan reuses ecosystem packages. Run the package steps above.');
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
185
191
|
// Agent review over the generated code — read-only, uses the same provider.
|
|
186
192
|
const agentReview = async (result) => {
|
|
187
193
|
if (flags['review'] !== true)
|
|
@@ -40,6 +40,8 @@ export interface PrismaModel {
|
|
|
40
40
|
fields: string[];
|
|
41
41
|
/** True when the model carries a `tenantId` column (tenant-scoped). */
|
|
42
42
|
tenantScoped: boolean;
|
|
43
|
+
/** The `@@map("…")` table name, when the model declares one. */
|
|
44
|
+
table?: string;
|
|
43
45
|
}
|
|
44
46
|
export interface PrismaInfo {
|
|
45
47
|
provider: DetectedStack['database'];
|
|
@@ -55,6 +57,8 @@ export interface AppFileInfo {
|
|
|
55
57
|
memorySources: string[];
|
|
56
58
|
/** Every `<name>Plugin(` factory call in the app file (unfiltered by capability). */
|
|
57
59
|
pluginCalls: string[];
|
|
60
|
+
/** Plugins called without the option that takes a durable store. */
|
|
61
|
+
defaultMemoryStores: DefaultMemoryStore[];
|
|
58
62
|
}
|
|
59
63
|
export interface ServerFileInfo {
|
|
60
64
|
path: string;
|
|
@@ -80,6 +84,43 @@ export interface CentralPlatformRouteHint {
|
|
|
80
84
|
/** 1-based line of the `meta` object. */
|
|
81
85
|
line: number;
|
|
82
86
|
}
|
|
87
|
+
/** A spot in a source file a rule points at. */
|
|
88
|
+
export interface SourceHint {
|
|
89
|
+
path: string;
|
|
90
|
+
/** 1-based line. */
|
|
91
|
+
line: number;
|
|
92
|
+
}
|
|
93
|
+
/** Findings from scanning the app's own source files under `src/`. */
|
|
94
|
+
export interface SourceHints {
|
|
95
|
+
/** `ctx()`, `tryCtx()` or `db()` called while a module loads, outside any function. */
|
|
96
|
+
moduleScopeContext: SourceHint[];
|
|
97
|
+
/** `new HookBus()`: a bus of its own, which the app's events never reach. */
|
|
98
|
+
detachedHookBus: SourceHint[];
|
|
99
|
+
/** Imports from `zod/v3`. */
|
|
100
|
+
zodV3Imports: SourceHint[];
|
|
101
|
+
}
|
|
102
|
+
/** What the project's Prisma migrations create. */
|
|
103
|
+
export interface MigrationsInfo {
|
|
104
|
+
/** The migrations folder, relative to the root. */
|
|
105
|
+
path: string;
|
|
106
|
+
/** `migration.sql` files found. */
|
|
107
|
+
files: number;
|
|
108
|
+
/** Tables those files create (or rename to), lower-cased, schema prefix dropped. */
|
|
109
|
+
tables: string[];
|
|
110
|
+
}
|
|
111
|
+
/** The reference schema an installed `@basaltkit/*-prisma` package ships. */
|
|
112
|
+
export interface FrameworkSchema {
|
|
113
|
+
package: string;
|
|
114
|
+
models: PrismaModel[];
|
|
115
|
+
}
|
|
116
|
+
/** A plugin left on the in-memory store it falls back to. */
|
|
117
|
+
export interface DefaultMemoryStore {
|
|
118
|
+
plugin: string;
|
|
119
|
+
/** The option that takes the durable store. */
|
|
120
|
+
option: string;
|
|
121
|
+
/** The package that provides one. */
|
|
122
|
+
durable: string;
|
|
123
|
+
}
|
|
83
124
|
export interface ProjectContext {
|
|
84
125
|
root: string;
|
|
85
126
|
/**
|
|
@@ -94,6 +135,16 @@ export interface ProjectContext {
|
|
|
94
135
|
app: AppFileInfo | null;
|
|
95
136
|
server: ServerFileInfo | null;
|
|
96
137
|
env: EnvFileInfo | null;
|
|
138
|
+
/**
|
|
139
|
+
* The Prisma migrations: null when there is no migrations folder (the
|
|
140
|
+
* project uses `db push`, or has no Prisma). Absent when the reader cannot
|
|
141
|
+
* list files.
|
|
142
|
+
*/
|
|
143
|
+
migrations?: MigrationsInfo | null;
|
|
144
|
+
/** Reference schemas of the installed `@basaltkit/*-prisma` packages. */
|
|
145
|
+
frameworkSchemas?: FrameworkSchema[];
|
|
146
|
+
/** Scan of the app's own source files. Absent when the reader cannot list files. */
|
|
147
|
+
sourceHints?: SourceHints;
|
|
97
148
|
}
|
|
98
149
|
/** Detect the project's stack and key files. Pure over the injected reader. */
|
|
99
150
|
export declare function detectProject(root: string, reader?: ProjectReader): ProjectContext;
|
package/dist/context/project.js
CHANGED
|
@@ -52,7 +52,8 @@ function trimTrailingSlashes(path) {
|
|
|
52
52
|
export function memoryReader(files) {
|
|
53
53
|
return {
|
|
54
54
|
read: (relPath) => files[relPath] ?? null,
|
|
55
|
-
exists
|
|
55
|
+
// A folder exists when a file sits under it, as on disk.
|
|
56
|
+
exists: (relPath) => relPath in files || Object.keys(files).some((path) => path.startsWith(`${relPath}/`)),
|
|
56
57
|
list: (relDir) => {
|
|
57
58
|
const prefix = `${trimTrailingSlashes(relDir)}/`;
|
|
58
59
|
return Object.keys(files).filter((path) => path.startsWith(prefix));
|
|
@@ -97,6 +98,8 @@ export function detectProject(root, reader = nodeReader(root)) {
|
|
|
97
98
|
const prisma = detectPrisma(reader);
|
|
98
99
|
const stack = buildStack(installed, app, prisma);
|
|
99
100
|
const centralPlatformRoutes = detectCentralPlatformRoutes(reader);
|
|
101
|
+
const migrations = detectMigrations(reader);
|
|
102
|
+
const sourceHints = detectSourceHints(reader);
|
|
100
103
|
return {
|
|
101
104
|
root,
|
|
102
105
|
installed,
|
|
@@ -105,9 +108,130 @@ export function detectProject(root, reader = nodeReader(root)) {
|
|
|
105
108
|
app,
|
|
106
109
|
server,
|
|
107
110
|
env,
|
|
111
|
+
frameworkSchemas: detectFrameworkSchemas(reader, installed, prisma?.provider ?? null),
|
|
108
112
|
...(centralPlatformRoutes ? { centralPlatformRoutes } : {}),
|
|
113
|
+
...(migrations !== undefined ? { migrations } : {}),
|
|
114
|
+
...(sourceHints ? { sourceHints } : {}),
|
|
109
115
|
};
|
|
110
116
|
}
|
|
117
|
+
/** The app's own source files: `src/**` scripts, without tests and declarations. */
|
|
118
|
+
function sourceFiles(reader) {
|
|
119
|
+
if (!reader.list)
|
|
120
|
+
return undefined;
|
|
121
|
+
return reader
|
|
122
|
+
.list('src')
|
|
123
|
+
.filter((path) => /\.[cm]?[jt]s$/.test(path) && !/\.d\.ts$|\.(test|spec)\.[cm]?[jt]s$/.test(path))
|
|
124
|
+
.slice(0, MAX_SCANNED_FILES);
|
|
125
|
+
}
|
|
126
|
+
const MIGRATIONS_DIR = 'prisma/migrations';
|
|
127
|
+
/**
|
|
128
|
+
* Tables the migrations create. Reads `CREATE TABLE` (and `RENAME TO`) from
|
|
129
|
+
* every `migration.sql`: what `prisma migrate deploy` builds in production.
|
|
130
|
+
*/
|
|
131
|
+
function detectMigrations(reader) {
|
|
132
|
+
if (!reader.list)
|
|
133
|
+
return undefined;
|
|
134
|
+
if (!reader.exists(MIGRATIONS_DIR))
|
|
135
|
+
return null;
|
|
136
|
+
const files = reader.list(MIGRATIONS_DIR).filter((path) => path.endsWith('/migration.sql'));
|
|
137
|
+
const tables = new Set();
|
|
138
|
+
const name = String.raw `(?:[\x60"\[]?\w+[\x60"\]]?\s*\.\s*)?[\x60"\[]?(\w+)[\x60"\]]?`;
|
|
139
|
+
const create = new RegExp(String.raw `\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?` + name, 'gi');
|
|
140
|
+
const rename = new RegExp(String.raw `\bRENAME\s+TO\s+` + name, 'gi');
|
|
141
|
+
for (const path of files) {
|
|
142
|
+
const sql = reader.read(path) ?? '';
|
|
143
|
+
for (const match of sql.matchAll(create))
|
|
144
|
+
if (match[1])
|
|
145
|
+
tables.add(match[1].toLowerCase());
|
|
146
|
+
for (const match of sql.matchAll(rename))
|
|
147
|
+
if (match[1])
|
|
148
|
+
tables.add(match[1].toLowerCase());
|
|
149
|
+
}
|
|
150
|
+
return { path: MIGRATIONS_DIR, files: files.length, tables: [...tables].sort() };
|
|
151
|
+
}
|
|
152
|
+
/** The reference schema of every installed `@basaltkit/*-prisma` package, in the app's dialect. */
|
|
153
|
+
function detectFrameworkSchemas(reader, installed, provider) {
|
|
154
|
+
const schemas = [];
|
|
155
|
+
for (const pkg of installed.filter((name) => name.endsWith('-prisma'))) {
|
|
156
|
+
const dir = `node_modules/${pkg}/prisma`;
|
|
157
|
+
const schema = (provider === 'mysql' ? reader.read(`${dir}/schema.mysql.prisma`) : null) ?? reader.read(`${dir}/schema.prisma`);
|
|
158
|
+
if (schema)
|
|
159
|
+
schemas.push({ package: pkg, models: parseModels(schema) });
|
|
160
|
+
}
|
|
161
|
+
return schemas;
|
|
162
|
+
}
|
|
163
|
+
/** The local names under which a module imports `wanted` from `source` (`{ db as getDb }` → getDb). */
|
|
164
|
+
function localNames(content, source, wanted) {
|
|
165
|
+
const names = [];
|
|
166
|
+
const from = new RegExp(String.raw `import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]${source.replace('/', '\\/')}['"]`, 'g');
|
|
167
|
+
for (const match of content.matchAll(from)) {
|
|
168
|
+
for (const part of (match[1] ?? '').split(',')) {
|
|
169
|
+
const [imported, local] = part.trim().split(/\s+as\s+/).map((name) => name.trim());
|
|
170
|
+
if (imported && wanted.includes(imported))
|
|
171
|
+
names.push(local ?? imported);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return names;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Scans `src/` for three mistakes a regex can see without false alarms:
|
|
178
|
+
* a request-context call in a module's top-level `const`, a HookBus of its
|
|
179
|
+
* own, and zod/v3 imports. Matches must start in code (comments and string
|
|
180
|
+
* text are blanked), as for the route scan.
|
|
181
|
+
*/
|
|
182
|
+
function detectSourceHints(reader) {
|
|
183
|
+
const files = sourceFiles(reader);
|
|
184
|
+
if (!files)
|
|
185
|
+
return undefined;
|
|
186
|
+
const hints = { moduleScopeContext: [], detachedHookBus: [], zodV3Imports: [] };
|
|
187
|
+
for (const path of files) {
|
|
188
|
+
const content = reader.read(path);
|
|
189
|
+
if (!content)
|
|
190
|
+
continue;
|
|
191
|
+
const code = codeOnly(content);
|
|
192
|
+
const lineOf = (index) => content.slice(0, index).split('\n').length;
|
|
193
|
+
const callees = [
|
|
194
|
+
...localNames(content, '@basaltkit/core', ['ctx', 'tryCtx']),
|
|
195
|
+
...localNames(content, '@basaltkit/prisma', ['db']),
|
|
196
|
+
];
|
|
197
|
+
if (callees.length > 0) {
|
|
198
|
+
const call = new RegExp(String.raw `\b(?:` + callees.join('|') + String.raw `)\s*(?:<[^<>()]*>)?\s*\(\s*\)`);
|
|
199
|
+
let depth = 0;
|
|
200
|
+
let offset = 0;
|
|
201
|
+
for (const line of code.split('\n')) {
|
|
202
|
+
// A top-level `const x = …` whose initializer, on that line, calls
|
|
203
|
+
// the context before any function begins: it runs at import time.
|
|
204
|
+
const declaration = depth === 0
|
|
205
|
+
? /^(?:export\s+)?(?:const|let|var)\s+[\w$]+(?:\s*:[^=]+)?\s*=(?![=>])(.*)$/.exec(line)
|
|
206
|
+
: null;
|
|
207
|
+
if (declaration) {
|
|
208
|
+
const initializer = declaration[1] ?? '';
|
|
209
|
+
const at = initializer.search(call);
|
|
210
|
+
const fn = initializer.search(/=>|\bfunction\b/);
|
|
211
|
+
if (at !== -1 && (fn === -1 || at < fn))
|
|
212
|
+
hints.moduleScopeContext.push({ path, line: lineOf(offset) });
|
|
213
|
+
}
|
|
214
|
+
for (const ch of line) {
|
|
215
|
+
if (ch === '{')
|
|
216
|
+
depth++;
|
|
217
|
+
else if (ch === '}')
|
|
218
|
+
depth = Math.max(0, depth - 1);
|
|
219
|
+
}
|
|
220
|
+
offset += line.length + 1;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (const match of code.matchAll(/\bnew\s+HookBus\s*\(/g)) {
|
|
224
|
+
hints.detachedHookBus.push({ path, line: lineOf(match.index ?? 0) });
|
|
225
|
+
}
|
|
226
|
+
for (const match of content.matchAll(/\b(?:from\s*|import\s*\(\s*|require\s*\(\s*)['"]zod\/v3['"]/g)) {
|
|
227
|
+
const index = match.index ?? 0;
|
|
228
|
+
if (!/^(from|import|require)/.test(code.slice(index, index + 7)))
|
|
229
|
+
continue;
|
|
230
|
+
hints.zodV3Imports.push({ path, line: lineOf(index) });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return hints;
|
|
234
|
+
}
|
|
111
235
|
/**
|
|
112
236
|
* Finds `meta: { … }` literals with `tenant: false` and a `can` naming
|
|
113
237
|
* `platform:…`. A regex over flat object literals, not a parser: it sees
|
|
@@ -189,7 +313,51 @@ function detectAppFile(reader) {
|
|
|
189
313
|
const pluginCalls = [
|
|
190
314
|
...new Set(Array.from(found.content.matchAll(/\b([A-Za-z][A-Za-z0-9]*Plugin)\s*\(/g), (m) => m[1])),
|
|
191
315
|
];
|
|
192
|
-
|
|
316
|
+
const defaultMemoryStores = MEMORY_DEFAULT_PLUGINS.filter((spec) => pluginCallArguments(found.content, spec.plugin).some((args) => !takesOption(args, spec.option)));
|
|
317
|
+
return { path: found.path, plugins, fastifyLoggerConfigured, memorySources, pluginCalls, defaultMemoryStores };
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Plugins that fall back to an in-memory store when the option is left out:
|
|
321
|
+
* fine in a test, lost on every restart and not shared between instances.
|
|
322
|
+
*/
|
|
323
|
+
const MEMORY_DEFAULT_PLUGINS = [
|
|
324
|
+
{ plugin: 'commentsPlugin', option: 'store', durable: '@basaltkit/comments-prisma' },
|
|
325
|
+
{ plugin: 'activityPlugin', option: 'store', durable: '@basaltkit/activity-prisma' },
|
|
326
|
+
{ plugin: 'auditPlugin', option: 'store', durable: '@basaltkit/audit-prisma' },
|
|
327
|
+
{ plugin: 'filesPlugin', option: 'store', durable: '@basaltkit/files-prisma' },
|
|
328
|
+
{ plugin: 'notificationsPlugin', option: 'inApp', durable: '@basaltkit/notifications-prisma' },
|
|
329
|
+
{ plugin: 'teamsPlugin', option: 'memberships', durable: '@basaltkit/teams-prisma' },
|
|
330
|
+
{ plugin: 'webhooksPlugin', option: 'store', durable: '@basaltkit/webhooks-prisma' },
|
|
331
|
+
{ plugin: 'outboxPlugin', option: 'store', durable: '@basaltkit/events-prisma' },
|
|
332
|
+
];
|
|
333
|
+
/** The argument text of every `<plugin>(…)` call in code, parentheses balanced. */
|
|
334
|
+
function pluginCallArguments(code, plugin) {
|
|
335
|
+
const out = [];
|
|
336
|
+
for (const match of code.matchAll(new RegExp(String.raw `\b${plugin}\s*\(`, 'g'))) {
|
|
337
|
+
let depth = 1;
|
|
338
|
+
let i = (match.index ?? 0) + match[0].length;
|
|
339
|
+
const start = i;
|
|
340
|
+
for (; i < code.length && depth > 0; i++) {
|
|
341
|
+
if (code[i] === '(')
|
|
342
|
+
depth++;
|
|
343
|
+
else if (code[i] === ')')
|
|
344
|
+
depth--;
|
|
345
|
+
}
|
|
346
|
+
out.push(code.slice(start, i - 1).trim());
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Whether the arguments pass `option`. Anything that is not an inline object
|
|
352
|
+
* (a variable, a spread) may carry it, so it counts as passed: the rule only
|
|
353
|
+
* speaks when it can see the option is missing.
|
|
354
|
+
*/
|
|
355
|
+
function takesOption(args, option) {
|
|
356
|
+
if (args === '')
|
|
357
|
+
return false;
|
|
358
|
+
if (!args.startsWith('{') || args.includes('...'))
|
|
359
|
+
return true;
|
|
360
|
+
return new RegExp(String.raw `[{,\s]${option}\s*[:,}]|[{,\s]${option}\s*$`).test(args);
|
|
193
361
|
}
|
|
194
362
|
/**
|
|
195
363
|
* The source with comments removed and string/template-literal TEXT blanked
|
|
@@ -329,8 +497,11 @@ function normalizeProvider(raw) {
|
|
|
329
497
|
function parseModels(schema) {
|
|
330
498
|
const models = [];
|
|
331
499
|
const modelRegex = /model\s+(\w+)\s*\{([^}]*)\}/gs;
|
|
500
|
+
// Comments go first: a `}` in one (`// { id, ...anything }`) would end the
|
|
501
|
+
// model there, losing every field and the @@map after it.
|
|
502
|
+
const source = stripComments(schema);
|
|
332
503
|
let match;
|
|
333
|
-
while ((match = modelRegex.exec(
|
|
504
|
+
while ((match = modelRegex.exec(source)) !== null) {
|
|
334
505
|
const name = match[1];
|
|
335
506
|
const body = match[2];
|
|
336
507
|
if (!name || body === undefined)
|
|
@@ -339,10 +510,15 @@ function parseModels(schema) {
|
|
|
339
510
|
.split('\n')
|
|
340
511
|
.map((line) => line.trim().split(/\s+/)[0])
|
|
341
512
|
.filter((token) => !!token && !token.startsWith('@') && !token.startsWith('//'));
|
|
342
|
-
|
|
513
|
+
const table = /@@map\(\s*"([^"]+)"\s*\)/.exec(body)?.[1];
|
|
514
|
+
models.push({ name, fields, tenantScoped: fields.includes('tenantId'), ...(table ? { table } : {}) });
|
|
343
515
|
}
|
|
344
516
|
return models;
|
|
345
517
|
}
|
|
518
|
+
/** Drops `//` comments — only at a line start or after whitespace, so `"postgres://…"` survives. */
|
|
519
|
+
function stripComments(schema) {
|
|
520
|
+
return schema.replace(/(^|\s)\/\/.*$/gm, '$1');
|
|
521
|
+
}
|
|
346
522
|
function buildStack(installed, app, prisma) {
|
|
347
523
|
const stack = {
|
|
348
524
|
http: null,
|
package/dist/doctor/rules.d.ts
CHANGED
|
@@ -5,3 +5,12 @@ import { type DoctorRule } from './types.js';
|
|
|
5
5
|
* {@link runDoctor}).
|
|
6
6
|
*/
|
|
7
7
|
export declare const DEFAULT_RULES: DoctorRule[];
|
|
8
|
+
/**
|
|
9
|
+
* Models the framework's `-prisma` packages ship without a tenantId, by design:
|
|
10
|
+
* identity is central (one AuthUser across tenants, membership checked by
|
|
11
|
+
* tenantMembershipPlugin), and the others are scoped by their package. Keyed by
|
|
12
|
+
* model name → its `@@map` table, and matched on both, so an app's own
|
|
13
|
+
* `Payment` or `Subscription` is still checked. A test keeps this in sync with
|
|
14
|
+
* the reference schemas.
|
|
15
|
+
*/
|
|
16
|
+
export declare const FRAMEWORK_GLOBAL_MODELS: Readonly<Record<string, string>>;
|
package/dist/doctor/rules.js
CHANGED
|
@@ -165,7 +165,7 @@ export const DEFAULT_RULES = [
|
|
|
165
165
|
check(ctx) {
|
|
166
166
|
if (!ctx.stack.tenancy || !ctx.prisma)
|
|
167
167
|
return null;
|
|
168
|
-
const unscoped = ctx.prisma.models.filter((model) => !model.tenantScoped && !GLOBAL_MODELS.has(model.name));
|
|
168
|
+
const unscoped = ctx.prisma.models.filter((model) => !model.tenantScoped && !GLOBAL_MODELS.has(model.name) && !isFrameworkGlobalModel(model));
|
|
169
169
|
if (unscoped.length === 0)
|
|
170
170
|
return null;
|
|
171
171
|
const names = unscoped.map((m) => m.name).join(', ');
|
|
@@ -183,6 +183,73 @@ export const DEFAULT_RULES = [
|
|
|
183
183
|
};
|
|
184
184
|
},
|
|
185
185
|
}),
|
|
186
|
+
// ── database ──────────────────────────────────────────────────────────────
|
|
187
|
+
defineRule({
|
|
188
|
+
id: 'schema-not-migrated',
|
|
189
|
+
category: 'database',
|
|
190
|
+
check(ctx) {
|
|
191
|
+
if (!ctx.prisma || !ctx.migrations)
|
|
192
|
+
return null;
|
|
193
|
+
const created = new Set(ctx.migrations.tables);
|
|
194
|
+
const missing = ctx.prisma.models.filter((model) => !created.has(tableOf(model).toLowerCase()));
|
|
195
|
+
if (missing.length === 0)
|
|
196
|
+
return null;
|
|
197
|
+
return {
|
|
198
|
+
id: 'schema-not-migrated',
|
|
199
|
+
title: 'Models with no migration — production will not have their tables',
|
|
200
|
+
severity: 'error',
|
|
201
|
+
category: 'database',
|
|
202
|
+
detected: `no migration in ${ctx.migrations.path} creates: ${listOf(missing.map((m) => m.name))}`,
|
|
203
|
+
recommended: 'create a migration for them and commit it',
|
|
204
|
+
reason: 'A deploy runs `prisma migrate deploy`, which builds only what the migrations ' +
|
|
205
|
+
'create. A table that exists locally because of `prisma db push` is missing in ' +
|
|
206
|
+
'production, and the first query on it fails.',
|
|
207
|
+
fix: [
|
|
208
|
+
'pnpm prisma migrate dev --name add_missing_tables',
|
|
209
|
+
'// a database built with db push reports drift: generate the SQL with `prisma migrate diff`,',
|
|
210
|
+
'// then mark it applied there with `prisma migrate resolve --applied <migration>`',
|
|
211
|
+
].join('\n'),
|
|
212
|
+
docs: '/guide/persistence',
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
defineRule({
|
|
217
|
+
id: 'framework-schema-drift',
|
|
218
|
+
category: 'database',
|
|
219
|
+
check(ctx) {
|
|
220
|
+
if (!ctx.prisma || !ctx.frameworkSchemas?.length)
|
|
221
|
+
return null;
|
|
222
|
+
const own = new Map(ctx.prisma.models.map((model) => [model.name, model]));
|
|
223
|
+
const gaps = [];
|
|
224
|
+
const packages = new Set();
|
|
225
|
+
for (const schema of ctx.frameworkSchemas) {
|
|
226
|
+
for (const reference of schema.models) {
|
|
227
|
+
const model = own.get(reference.name);
|
|
228
|
+
if (!model)
|
|
229
|
+
continue;
|
|
230
|
+
const missing = reference.fields.filter((field) => !model.fields.includes(field));
|
|
231
|
+
if (missing.length === 0)
|
|
232
|
+
continue;
|
|
233
|
+
gaps.push(...missing.map((field) => `${model.name}.${field}`));
|
|
234
|
+
packages.add(schema.package);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (gaps.length === 0)
|
|
238
|
+
return null;
|
|
239
|
+
return {
|
|
240
|
+
id: 'framework-schema-drift',
|
|
241
|
+
title: "Framework models are missing columns of the installed packages' schemas",
|
|
242
|
+
severity: 'warning',
|
|
243
|
+
category: 'database',
|
|
244
|
+
detected: `missing from schema.prisma (${[...packages].join(', ')}): ${listOf(gaps)}`,
|
|
245
|
+
recommended: 'copy the current models from the installed packages and migrate',
|
|
246
|
+
reason: 'A package upgrade can add columns its store writes. Until the schema has them, ' +
|
|
247
|
+
'the store fails at runtime on the feature that uses them.',
|
|
248
|
+
fix: 'pnpm basalt prisma:sync # then: pnpm prisma migrate dev',
|
|
249
|
+
docs: '/guide/persistence',
|
|
250
|
+
};
|
|
251
|
+
},
|
|
252
|
+
}),
|
|
186
253
|
// ── durability ────────────────────────────────────────────────────────────
|
|
187
254
|
defineRule({
|
|
188
255
|
id: 'memory-sources-in-use',
|
|
@@ -254,6 +321,137 @@ export const DEFAULT_RULES = [
|
|
|
254
321
|
};
|
|
255
322
|
},
|
|
256
323
|
}),
|
|
324
|
+
defineRule({
|
|
325
|
+
id: 'plugin-default-memory-store',
|
|
326
|
+
category: 'durability',
|
|
327
|
+
check(ctx) {
|
|
328
|
+
const stores = ctx.app?.defaultMemoryStores ?? [];
|
|
329
|
+
if (!ctx.app || !ctx.stack.orm || stores.length === 0)
|
|
330
|
+
return null;
|
|
331
|
+
return {
|
|
332
|
+
id: 'plugin-default-memory-store',
|
|
333
|
+
title: 'Plugins keep their data in memory although the app has a database',
|
|
334
|
+
severity: 'warning',
|
|
335
|
+
category: 'durability',
|
|
336
|
+
detected: `${ctx.app.path}: ${stores.map((s) => `${s.plugin}() without ${s.option}`).join(', ')}`,
|
|
337
|
+
recommended: 'pass each one the durable store of its -prisma package',
|
|
338
|
+
reason: 'Left without a store these plugins fall back to memory: the data is gone on ' +
|
|
339
|
+
'every restart and each instance sees its own copy.',
|
|
340
|
+
fix: stores.map((s) => `pnpm add ${s.durable} // ${s.plugin}({ ${s.option}: … })`).join('\n'),
|
|
341
|
+
docs: '/guide/persistence',
|
|
342
|
+
};
|
|
343
|
+
},
|
|
344
|
+
}),
|
|
345
|
+
// ── correctness ───────────────────────────────────────────────────────────
|
|
346
|
+
defineRule({
|
|
347
|
+
id: 'context-at-module-scope',
|
|
348
|
+
category: 'tenancy',
|
|
349
|
+
check(ctx) {
|
|
350
|
+
const hits = ctx.sourceHints?.moduleScopeContext ?? [];
|
|
351
|
+
if (hits.length === 0)
|
|
352
|
+
return null;
|
|
353
|
+
return {
|
|
354
|
+
id: 'context-at-module-scope',
|
|
355
|
+
title: 'The request context is read while a module loads',
|
|
356
|
+
severity: 'error',
|
|
357
|
+
category: 'tenancy',
|
|
358
|
+
detected: `ctx()/tryCtx()/db() in a top-level declaration: ${whereOf(hits)}`,
|
|
359
|
+
recommended: 'call it inside the handler or function that needs it',
|
|
360
|
+
reason: 'At import time there is no request: ctx() and db() throw, and tryCtx() returns ' +
|
|
361
|
+
'undefined, so the value is the same for every tenant and every request.',
|
|
362
|
+
fix: 'const service = () => ctx().container.get(SERVICE) // a function, called per request',
|
|
363
|
+
docs: '/guide/concepts#context-asynclocalstorage',
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
}),
|
|
367
|
+
defineRule({
|
|
368
|
+
id: 'detached-hook-bus',
|
|
369
|
+
category: 'config',
|
|
370
|
+
check(ctx) {
|
|
371
|
+
const hits = ctx.sourceHints?.detachedHookBus ?? [];
|
|
372
|
+
if (hits.length === 0)
|
|
373
|
+
return null;
|
|
374
|
+
return {
|
|
375
|
+
id: 'detached-hook-bus',
|
|
376
|
+
title: 'A HookBus of its own — the app\'s events never reach it',
|
|
377
|
+
severity: 'warning',
|
|
378
|
+
category: 'config',
|
|
379
|
+
detected: `new HookBus() in ${whereOf(hits)}`,
|
|
380
|
+
recommended: "listen on the app's bus: the `hooks` a plugin receives",
|
|
381
|
+
reason: 'Plugins emit on the bus the app creates. A bus built with new HookBus() is a ' +
|
|
382
|
+
'separate one: its listeners are never called and nothing fails.',
|
|
383
|
+
fix: "definePlugin({ name: 'x', boot({ hooks }) { hooks.on('comment:created', …) } })",
|
|
384
|
+
docs: '/guide/concepts#hooks-hookbus',
|
|
385
|
+
};
|
|
386
|
+
},
|
|
387
|
+
}),
|
|
388
|
+
defineRule({
|
|
389
|
+
id: 'zod-v3-import',
|
|
390
|
+
category: 'config',
|
|
391
|
+
check(ctx) {
|
|
392
|
+
const hits = ctx.sourceHints?.zodV3Imports ?? [];
|
|
393
|
+
if (hits.length === 0)
|
|
394
|
+
return null;
|
|
395
|
+
return {
|
|
396
|
+
id: 'zod-v3-import',
|
|
397
|
+
title: 'Schemas built with zod/v3',
|
|
398
|
+
severity: 'warning',
|
|
399
|
+
category: 'config',
|
|
400
|
+
detected: `imports from 'zod/v3': ${whereOf(hits)}`,
|
|
401
|
+
recommended: "import from 'zod' (Zod 4), as the framework does",
|
|
402
|
+
reason: 'Routes turn their schemas into OpenAPI and MCP tool input with Zod 4. A zod/v3 ' +
|
|
403
|
+
'schema is a different object: those documents come out wrong or the conversion throws.',
|
|
404
|
+
fix: "import { z } from 'zod'",
|
|
405
|
+
docs: '/guide/concepts#the-neutral-route-pipeline',
|
|
406
|
+
};
|
|
407
|
+
},
|
|
408
|
+
}),
|
|
257
409
|
];
|
|
258
410
|
/** Models that legitimately have no tenantId (platform-global tables). */
|
|
259
411
|
const GLOBAL_MODELS = new Set(['Tenant', 'User', 'Plan', 'WebhookEvent', 'Migration']);
|
|
412
|
+
/**
|
|
413
|
+
* Models the framework's `-prisma` packages ship without a tenantId, by design:
|
|
414
|
+
* identity is central (one AuthUser across tenants, membership checked by
|
|
415
|
+
* tenantMembershipPlugin), and the others are scoped by their package. Keyed by
|
|
416
|
+
* model name → its `@@map` table, and matched on both, so an app's own
|
|
417
|
+
* `Payment` or `Subscription` is still checked. A test keeps this in sync with
|
|
418
|
+
* the reference schemas.
|
|
419
|
+
*/
|
|
420
|
+
export const FRAMEWORK_GLOBAL_MODELS = {
|
|
421
|
+
AuthUser: 'auth_users',
|
|
422
|
+
AuthSession: 'auth_sessions',
|
|
423
|
+
AuthRefreshToken: 'auth_refresh_tokens',
|
|
424
|
+
AuthToken: 'auth_tokens',
|
|
425
|
+
AuthMfa: 'auth_mfa',
|
|
426
|
+
AuthTokenVersion: 'auth_token_versions',
|
|
427
|
+
AuthAccountLink: 'auth_account_links',
|
|
428
|
+
AuthPasskey: 'auth_passkeys',
|
|
429
|
+
InAppNotification: 'in_app_notifications',
|
|
430
|
+
NotificationPreference: 'notification_preferences',
|
|
431
|
+
PermUserRole: 'perm_user_roles',
|
|
432
|
+
PermUserPermission: 'perm_user_permissions',
|
|
433
|
+
PermRolePermission: 'perm_role_permissions',
|
|
434
|
+
PermTemporaryGrant: 'perm_temporary_grants',
|
|
435
|
+
PermDelegation: 'perm_delegations',
|
|
436
|
+
Subscription: 'subscriptions',
|
|
437
|
+
UsageCounter: 'usage_counters',
|
|
438
|
+
WebhookEvent: 'webhook_events',
|
|
439
|
+
Payment: 'payments',
|
|
440
|
+
RecurringSubscription: 'recurring_subscriptions',
|
|
441
|
+
Tenant: 'tenants',
|
|
442
|
+
};
|
|
443
|
+
function isFrameworkGlobalModel(model) {
|
|
444
|
+
const table = Object.hasOwn(FRAMEWORK_GLOBAL_MODELS, model.name) ? FRAMEWORK_GLOBAL_MODELS[model.name] : undefined;
|
|
445
|
+
return table !== undefined && table === model.table;
|
|
446
|
+
}
|
|
447
|
+
/** The table a model maps to: its `@@map`, else its name. */
|
|
448
|
+
function tableOf(model) {
|
|
449
|
+
return model.table ?? model.name;
|
|
450
|
+
}
|
|
451
|
+
/** A list cut at ten entries, so a big schema does not flood the report. */
|
|
452
|
+
function listOf(items) {
|
|
453
|
+
return items.length > 10 ? `${items.slice(0, 10).join(', ')} (+${items.length - 10} more)` : items.join(', ');
|
|
454
|
+
}
|
|
455
|
+
function whereOf(hits) {
|
|
456
|
+
return listOf(hits.map((hit) => `${hit.path}:${hit.line}`));
|
|
457
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { fetchWithRetry, type RetryOptions } from './provider/http.js';
|
|
|
6
6
|
export { globalSseFetch, parseSseContent, type SseFetch, type SseResponse } from './provider/sse.js';
|
|
7
7
|
export { createProvider, providerEnvFrom, providerEnvFromProcess, type CreateProviderOptions, type ProviderEnv, type ProviderName, } from './provider/factory.js';
|
|
8
8
|
export { runGeneration, generateText, withAbort, throwIfAborted, abortError, isAbortError, type OnProgress, type WorkflowProgress, type WorkflowRunOptions, } from './generate.js';
|
|
9
|
-
export { detectProject, nodeReader, memoryReader, type AppFileInfo, type CentralPlatformRouteHint, type DetectedStack, type EnvFileInfo, type PrismaInfo, type PrismaModel, type ProjectContext, type ProjectReader, type ServerFileInfo, } from './context/project.js';
|
|
9
|
+
export { detectProject, nodeReader, memoryReader, type AppFileInfo, type CentralPlatformRouteHint, type DefaultMemoryStore, type DetectedStack, type EnvFileInfo, type FrameworkSchema, type MigrationsInfo, type PrismaInfo, type PrismaModel, type ProjectContext, type ProjectReader, type ServerFileInfo, type SourceHint, type SourceHints, } from './context/project.js';
|
|
10
10
|
export { defineRule, type Diagnostic, type DiagnosticCategory, type DoctorRule, type Severity, } from './doctor/types.js';
|
|
11
11
|
export { DEFAULT_RULES } from './doctor/rules.js';
|
|
12
12
|
export { hasErrors, runDoctor } from './doctor/run.js';
|
|
@@ -15,6 +15,7 @@ export { analyze, type AnalysisReport } from './analyze/run.js';
|
|
|
15
15
|
export { createPlan, parsePlan, type CreatePlanOptions } from './plan/plan.js';
|
|
16
16
|
export { buildPlanContext } from './plan/context.js';
|
|
17
17
|
export { BASALT_KNOWLEDGE } from './plan/knowledge.js';
|
|
18
|
+
export { ECOSYSTEM_PACKAGES, renderEcosystemCatalog, type EcosystemPackage } from './plan/packages.js';
|
|
18
19
|
export { renderPlan } from './plan/render.js';
|
|
19
20
|
export type { ArchitecturePlan, PlanEntity, PlanField, PlanRelation, PlanStep, PlanStepKind, } from './plan/types.js';
|
|
20
21
|
export { runMake } from './make/make.js';
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export { analyze } from './analyze/run.js';
|
|
|
21
21
|
export { createPlan, parsePlan } from './plan/plan.js';
|
|
22
22
|
export { buildPlanContext } from './plan/context.js';
|
|
23
23
|
export { BASALT_KNOWLEDGE } from './plan/knowledge.js';
|
|
24
|
+
export { ECOSYSTEM_PACKAGES, renderEcosystemCatalog } from './plan/packages.js';
|
|
24
25
|
export { renderPlan } from './plan/render.js';
|
|
25
26
|
// Implementation (spec §4 Implement) — execute a plan via the generator + review gate.
|
|
26
27
|
export { runMake } from './make/make.js';
|
package/dist/make/make.d.ts
CHANGED
|
@@ -12,3 +12,5 @@ import type { MakeOptions, MakeResult } from './types.js';
|
|
|
12
12
|
* touches the database, never deletes.
|
|
13
13
|
*/
|
|
14
14
|
export declare function runMake(ctx: ProjectContext, plan: ArchitecturePlan, options?: MakeOptions): Promise<MakeResult>;
|
|
15
|
+
/** The plan's `package` steps as instructions — ai:make never installs dependencies itself. */
|
|
16
|
+
export declare function packageSteps(plan: ArchitecturePlan): string[];
|
package/dist/make/make.js
CHANGED
|
@@ -28,7 +28,10 @@ export async function runMake(ctx, plan, options = {}) {
|
|
|
28
28
|
assertSafePlan(plan);
|
|
29
29
|
const entities = plan.entities.filter((e) => e.name.trim() !== '');
|
|
30
30
|
if (entities.length === 0) {
|
|
31
|
-
|
|
31
|
+
const packages = packageSteps(plan);
|
|
32
|
+
throw new Error(packages.length > 0
|
|
33
|
+
? `ai:make — nothing to generate: the plan reuses ecosystem packages. ${packages.join(' ')}`
|
|
34
|
+
: 'ai:make — the plan has no entity to generate.');
|
|
32
35
|
}
|
|
33
36
|
throwIfAborted(options.signal);
|
|
34
37
|
const baseDir = options.baseDir ?? process.cwd();
|
|
@@ -222,7 +225,8 @@ function augmentFiles(files, entity, plan, ctx, opts) {
|
|
|
222
225
|
return { files: out, augmented, guarded, audited };
|
|
223
226
|
}
|
|
224
227
|
function buildFollowUps(plan, resources, schema, migration) {
|
|
225
|
-
|
|
228
|
+
// Packages first: the generated code may depend on them being wired.
|
|
229
|
+
const followUps = packageSteps(plan);
|
|
226
230
|
const prismaResources = resources.filter((r) => r.prisma);
|
|
227
231
|
if (prismaResources.length > 0) {
|
|
228
232
|
if (!schema?.found) {
|
|
@@ -257,6 +261,12 @@ function buildFollowUps(plan, resources, schema, migration) {
|
|
|
257
261
|
}
|
|
258
262
|
return followUps;
|
|
259
263
|
}
|
|
264
|
+
/** The plan's `package` steps as instructions — ai:make never installs dependencies itself. */
|
|
265
|
+
export function packageSteps(plan) {
|
|
266
|
+
return plan.steps
|
|
267
|
+
.filter((step) => step.kind === 'package')
|
|
268
|
+
.map((step) => `${step.title}${step.command ? ` — \`${step.command}\`` : ''}${step.detail ? `: ${step.detail}` : ''}`);
|
|
269
|
+
}
|
|
260
270
|
function reviewBuild(ctx, plan, resources, schema, migration) {
|
|
261
271
|
const items = [];
|
|
262
272
|
// Tenant isolation — the gate the spec cares most about.
|
package/dist/plan/context.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ProjectContext } from '../context/project.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Compact, relevant-only project summary for the planner (spec §18 Context
|
|
4
4
|
* Engineering — never ship the whole project to the model). Surfaces exactly what
|
|
5
|
-
* the Architect needs to ground a plan: enabled capabilities, existing models
|
|
6
|
-
* the generator's flags.
|
|
5
|
+
* the Architect needs to ground a plan: enabled capabilities, existing models,
|
|
6
|
+
* the ecosystem packages already installed and the generator's flags.
|
|
7
7
|
*/
|
|
8
8
|
export declare function buildPlanContext(ctx: ProjectContext): string;
|
package/dist/plan/context.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { ECOSYSTEM_PACKAGES } from './packages.js';
|
|
1
2
|
/**
|
|
2
3
|
* Compact, relevant-only project summary for the planner (spec §18 Context
|
|
3
4
|
* Engineering — never ship the whole project to the model). Surfaces exactly what
|
|
4
|
-
* the Architect needs to ground a plan: enabled capabilities, existing models
|
|
5
|
-
* the generator's flags.
|
|
5
|
+
* the Architect needs to ground a plan: enabled capabilities, existing models,
|
|
6
|
+
* the ecosystem packages already installed and the generator's flags.
|
|
6
7
|
*/
|
|
7
8
|
export function buildPlanContext(ctx) {
|
|
8
9
|
const s = ctx.stack;
|
|
@@ -25,12 +26,18 @@ export function buildPlanContext(ctx) {
|
|
|
25
26
|
const models = ctx.prisma && ctx.prisma.models.length > 0
|
|
26
27
|
? ctx.prisma.models.map((m) => `${m.name}${m.tenantScoped ? ' (tenantId)' : ''}`).join(', ')
|
|
27
28
|
: '(none)';
|
|
29
|
+
// Catalog packages only: the store/driver packages (-prisma, -s3…) are
|
|
30
|
+
// implementation detail the planner reaches through the catalog entry.
|
|
31
|
+
const catalog = new Set(ECOSYSTEM_PACKAGES.map((p) => p.name));
|
|
32
|
+
const installed = ctx.installed.filter((name) => catalog.has(name));
|
|
28
33
|
const lines = [
|
|
29
34
|
'PROJECT STACK:',
|
|
30
35
|
...caps.map((c) => `- ${c}`),
|
|
31
36
|
'',
|
|
32
37
|
`EXISTING PRISMA MODELS: ${models}`,
|
|
33
38
|
'',
|
|
39
|
+
`INSTALLED ECOSYSTEM PACKAGES: ${installed.join(', ') || '(none)'} — use these as they are; any other catalog package needs a "package" install step.`,
|
|
40
|
+
'',
|
|
34
41
|
'GENERATOR: `basalt make:resource <Name>` — flags: --prisma, --soft-delete, --no-register, --dir, --force.',
|
|
35
42
|
];
|
|
36
43
|
if (ctx.app)
|
package/dist/plan/knowledge.d.ts
CHANGED
|
@@ -6,4 +6,4 @@
|
|
|
6
6
|
* This is deliberately prescriptive: the plan must reuse the generator and the
|
|
7
7
|
* platform plugins, never hand-roll parallel abstractions.
|
|
8
8
|
*/
|
|
9
|
-
export declare const BASALT_KNOWLEDGE
|
|
9
|
+
export declare const BASALT_KNOWLEDGE: string;
|
package/dist/plan/knowledge.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { renderEcosystemCatalog } from './packages.js';
|
|
1
2
|
/**
|
|
2
3
|
* The Architect agent's system prompt — Basalt's conventions and official APIs,
|
|
3
4
|
* encoded so the model plans *with* the framework instead of inventing its own
|
|
@@ -10,6 +11,11 @@ export const BASALT_KNOWLEDGE = `You are the Architect agent for Basalt — a ba
|
|
|
10
11
|
|
|
11
12
|
ABSOLUTE RULES — always use the framework's official APIs and generators, never invent new ones:
|
|
12
13
|
|
|
14
|
+
0. ECOSYSTEM FIRST. Before planning any resource, check whether an official package already covers the capability (catalog below). If one does, use it: a "package" step whose "command" installs it (\`pnpm add <package>\`, plus its Prisma store when the app uses Prisma) and whose "detail" says how to wire its plugin/routes in src/app.ts — or, when the project already has it installed, a step that only wires/uses it. NEVER generate a resource, model or module that duplicates a package (no Comment model when @basaltkit/comments exists, no Notification table, no File/Attachment model, no Team/Invitation model, no AuditLog). Do NOT list a package-covered concept in "entities" — entities are only the domain models no package provides. Only generate from scratch what the catalog does not cover, and say so in "warnings" when a package covers it only partly.
|
|
15
|
+
|
|
16
|
+
ECOSYSTEM CATALOG:
|
|
17
|
+
${renderEcosystemCatalog()}
|
|
18
|
+
|
|
13
19
|
1. SCAFFOLDING. Create a resource with the generator, not by hand:
|
|
14
20
|
\`basalt make:resource <Name>\` — generates a full vertical: Zod schema, repository, service, DI plugin, typed routes and a test, all on-convention and auto-wired into src/app.ts.
|
|
15
21
|
Flags: --prisma (Prisma-backed repository + a schema.prisma model with id/createdAt/updatedAt), --soft-delete (adds deletedAt, a restore() method + route), --no-register (skip wiring), --dir=<path>, --force.
|
|
@@ -36,14 +42,14 @@ OUTPUT: Return ONLY a single JSON object (no prose, no markdown fences) matching
|
|
|
36
42
|
"relations": [{ "name": string, "model": string }] } // belongs-to: this model gets a <name>Id FK → <model>
|
|
37
43
|
],
|
|
38
44
|
"steps": [ // ordered implementation steps
|
|
39
|
-
{ "order": number, "title": string, "kind": "generator"|"schema"|"migration"|"service"|"routes"|"permissions"|"audit"|"test"|"docs"|"other", "detail": string, "command": string, "files": string[] }
|
|
45
|
+
{ "order": number, "title": string, "kind": "package"|"generator"|"schema"|"migration"|"service"|"routes"|"permissions"|"audit"|"test"|"docs"|"other", "detail": string, "command": string, "files": string[] }
|
|
40
46
|
],
|
|
41
47
|
"permissions": string[], // e.g. ["patients.view","patients.create",...]
|
|
42
48
|
"auditEvents": string[], // e.g. ["patient.created",...]
|
|
43
49
|
"tenantScoped": boolean, // does this feature involve tenant-owned data
|
|
44
50
|
"warnings": string[] // risks, decisions to confirm, missing info
|
|
45
51
|
}
|
|
46
|
-
For generator steps, put the exact command in "command" (e.g. "basalt make:resource Patient --prisma --soft-delete"). Omit "command"/"files" when not applicable.
|
|
52
|
+
For generator steps, put the exact command in "command" (e.g. "basalt make:resource Patient --prisma --soft-delete"); for package steps, the install command (e.g. "pnpm add @basaltkit/comments @basaltkit/comments-prisma"), or omit it when the package is already installed. Omit "command"/"files" when not applicable.
|
|
47
53
|
|
|
48
54
|
ENUMS: a field with a fixed set of values (a status/state) MUST set "enum" to those values — e.g. "estado (pago/pendente)" → { "name": "estado", "type": "String", "enum": ["pago", "pendente"] }. It becomes a validated z.enum([...]) (stored as a String column), never a free z.string().
|
|
49
55
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ecosystem catalog the Architect plans with: each official package, the
|
|
3
|
+
* capability it covers and the entry points an app wires. Without it the model
|
|
4
|
+
* only knew the generator and rebuilt features (comments, files, notifications…)
|
|
5
|
+
* as fresh resources instead of installing the package that already ships them.
|
|
6
|
+
*
|
|
7
|
+
* Kept in sync with `packages/*` by `tests/plan-packages.test.ts`.
|
|
8
|
+
*/
|
|
9
|
+
export interface EcosystemPackage {
|
|
10
|
+
/** npm name, e.g. `@basaltkit/comments`. */
|
|
11
|
+
name: string;
|
|
12
|
+
/** What it provides, in the words a request would use. */
|
|
13
|
+
covers: string;
|
|
14
|
+
/** The plugin factory wired in src/app.ts. */
|
|
15
|
+
plugin?: string;
|
|
16
|
+
/** Ready-made routes the app spreads into its route list. */
|
|
17
|
+
routes?: string;
|
|
18
|
+
/** The Prisma-backed store package, when the default store is in-memory. */
|
|
19
|
+
prisma?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare const ECOSYSTEM_PACKAGES: readonly EcosystemPackage[];
|
|
22
|
+
/** One catalog line per package, for the planner's system prompt. */
|
|
23
|
+
export declare function renderEcosystemCatalog(packages?: readonly EcosystemPackage[]): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const ECOSYSTEM_PACKAGES = [
|
|
2
|
+
{ name: '@basaltkit/auth', covers: 'login, signup, JWT/sessions, password reset, MFA, OAuth, passkeys, API keys', plugin: 'authPlugin', prisma: '@basaltkit/auth-prisma' },
|
|
3
|
+
{ name: '@basaltkit/permissions', covers: 'roles, permissions, policies, route guards (RBAC)', plugin: 'permissionsPlugin', prisma: '@basaltkit/permissions-prisma' },
|
|
4
|
+
{ name: '@basaltkit/tenancy', covers: 'multi-tenancy: tenant resolution and tenant context', plugin: 'tenancyPlugin', prisma: '@basaltkit/tenancy-prisma' },
|
|
5
|
+
{ name: '@basaltkit/teams', covers: 'team members, roles inside a tenant, email invitations', plugin: 'teamsPlugin', routes: 'teamRoutes', prisma: '@basaltkit/teams-prisma' },
|
|
6
|
+
{ name: '@basaltkit/comments', covers: 'comment threads on any resource, @mentions, resolve/reopen', plugin: 'commentsPlugin', routes: 'commentRoutes', prisma: '@basaltkit/comments-prisma' },
|
|
7
|
+
{ name: '@basaltkit/activity', covers: 'user-facing activity feed / history of changes', plugin: 'activityPlugin', prisma: '@basaltkit/activity-prisma' },
|
|
8
|
+
{ name: '@basaltkit/audit', covers: 'append-only audit trail of state changes', plugin: 'auditPlugin', prisma: '@basaltkit/audit-prisma' },
|
|
9
|
+
{ name: '@basaltkit/notifications', covers: 'in-app and email notifications, per-user preferences', plugin: 'notificationsPlugin', routes: 'inAppRoutes', prisma: '@basaltkit/notifications-prisma' },
|
|
10
|
+
{ name: '@basaltkit/files', covers: 'file uploads and attachments, quotas, signed URLs', plugin: 'filesPlugin', routes: 'fileRoutes', prisma: '@basaltkit/files-prisma' },
|
|
11
|
+
{ name: '@basaltkit/files-versions', covers: 'document versions / revisions on top of files', plugin: 'fileVersionsPlugin' },
|
|
12
|
+
{ name: '@basaltkit/storage', covers: 'disk/object storage (local, S3, Azure, GCS)', plugin: 'storagePlugin' },
|
|
13
|
+
{ name: '@basaltkit/drives', covers: 'import from Google Drive, OneDrive/SharePoint, Dropbox', plugin: 'drivesPlugin' },
|
|
14
|
+
{ name: '@basaltkit/subscriptions', covers: 'billing: plans, subscriptions, trials, usage limits, payment gateways', plugin: 'subscriptionsPlugin', prisma: '@basaltkit/subscriptions-prisma' },
|
|
15
|
+
{ name: '@basaltkit/webhooks', covers: 'outbound webhooks with signing and retries', plugin: 'webhooksPlugin', prisma: '@basaltkit/webhooks-prisma' },
|
|
16
|
+
{ name: '@basaltkit/events', covers: 'typed domain events, listeners, transactional outbox', plugin: 'eventsPlugin', prisma: '@basaltkit/events-prisma' },
|
|
17
|
+
{ name: '@basaltkit/queue', covers: 'background jobs (BullMQ, RabbitMQ, SQS, Kafka)', plugin: 'queuePlugin' },
|
|
18
|
+
{ name: '@basaltkit/scheduler', covers: 'scheduled / recurring tasks (cron-like)', plugin: 'schedulerPlugin' },
|
|
19
|
+
{ name: '@basaltkit/mailer', covers: 'sending email (SMTP, Resend, SES, Mailgun)', plugin: 'mailerPlugin' },
|
|
20
|
+
{ name: '@basaltkit/inbound-mail', covers: 'receiving email', routes: 'inboundMailRoutes' },
|
|
21
|
+
{ name: '@basaltkit/realtime', covers: 'live updates over WebSocket/SSE, presence', plugin: 'realtimePlugin' },
|
|
22
|
+
{ name: '@basaltkit/search', covers: 'full-text search (Postgres, Elasticsearch, Meilisearch)', plugin: 'searchPlugin' },
|
|
23
|
+
{ name: '@basaltkit/cache', covers: 'caching with tags and tenant scoping', plugin: 'cachePlugin' },
|
|
24
|
+
{ name: '@basaltkit/flags', covers: 'feature flags and percentage rollouts', plugin: 'flagsPlugin' },
|
|
25
|
+
{ name: '@basaltkit/exports', covers: 'CSV/JSON/XLSX exports and CSV import', plugin: 'exportsPlugin' },
|
|
26
|
+
{ name: '@basaltkit/i18n', covers: 'translations, locale, number/date formatting' },
|
|
27
|
+
{ name: '@basaltkit/backup', covers: 'PostgreSQL backups', plugin: 'backupPlugin' },
|
|
28
|
+
{ name: '@basaltkit/mcp', covers: 'expose routes as MCP tools for AI agents', plugin: 'mcpPlugin' },
|
|
29
|
+
];
|
|
30
|
+
/** One catalog line per package, for the planner's system prompt. */
|
|
31
|
+
export function renderEcosystemCatalog(packages = ECOSYSTEM_PACKAGES) {
|
|
32
|
+
return packages
|
|
33
|
+
.map((p) => {
|
|
34
|
+
const wiring = [p.plugin, p.routes ? `${p.routes}()` : undefined].filter(Boolean).join(' + ');
|
|
35
|
+
const store = p.prisma ? `; Prisma store: ${p.prisma}` : '';
|
|
36
|
+
return `- ${p.name} — ${p.covers}${wiring ? ` (wire: ${wiring}${store})` : store ? ` (${store.slice(2)})` : ''}`;
|
|
37
|
+
})
|
|
38
|
+
.join('\n');
|
|
39
|
+
}
|
package/dist/plan/plan.js
CHANGED
package/dist/plan/render.js
CHANGED
|
@@ -51,5 +51,5 @@ export function renderPlan(plan, io) {
|
|
|
51
51
|
}
|
|
52
52
|
io.log('');
|
|
53
53
|
io.log('This is a plan only — nothing was changed.');
|
|
54
|
-
io.log(
|
|
54
|
+
io.log(`Next: basalt ai:make "${plan.request}" implements it.`);
|
|
55
55
|
}
|
package/dist/plan/types.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
export type PlanStepKind =
|
|
1
|
+
export type PlanStepKind =
|
|
2
|
+
/** Install and/or wire an official `@basaltkit/*` package instead of generating code. */
|
|
3
|
+
'package' | 'generator' | 'schema' | 'migration' | 'service' | 'routes' | 'permissions' | 'audit' | 'test' | 'docs' | 'other';
|
|
2
4
|
export interface PlanField {
|
|
3
5
|
name: string;
|
|
4
6
|
type: string;
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -230,6 +230,7 @@ export declare const PlanStepKindSchema: z.ZodEnum<{
|
|
|
230
230
|
generator: "generator";
|
|
231
231
|
migration: "migration";
|
|
232
232
|
other: "other";
|
|
233
|
+
package: "package";
|
|
233
234
|
permissions: "permissions";
|
|
234
235
|
routes: "routes";
|
|
235
236
|
schema: "schema";
|
|
@@ -245,6 +246,7 @@ export declare const PlanStepSchema: z.ZodObject<{
|
|
|
245
246
|
generator: "generator";
|
|
246
247
|
migration: "migration";
|
|
247
248
|
other: "other";
|
|
249
|
+
package: "package";
|
|
248
250
|
permissions: "permissions";
|
|
249
251
|
routes: "routes";
|
|
250
252
|
schema: "schema";
|
|
@@ -281,6 +283,7 @@ export declare const ArchitecturePlanSchema: z.ZodObject<{
|
|
|
281
283
|
generator: "generator";
|
|
282
284
|
migration: "migration";
|
|
283
285
|
other: "other";
|
|
286
|
+
package: "package";
|
|
284
287
|
permissions: "permissions";
|
|
285
288
|
routes: "routes";
|
|
286
289
|
schema: "schema";
|
package/dist/schema/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/ai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=22.5.0"
|
|
6
6
|
},
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"zod": "^4.6.7",
|
|
34
|
-
"@basaltkit/cli": "^1.
|
|
34
|
+
"@basaltkit/cli": "^1.7.1",
|
|
35
35
|
"@basaltkit/generator": "^1.7.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|