@basaltkit/ai 0.11.0 → 1.0.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.
Files changed (74) hide show
  1. package/README.md +4 -3
  2. package/dist/analyze/run.d.ts +19 -0
  3. package/dist/analyze/run.js +58 -0
  4. package/dist/commands.d.ts +19 -0
  5. package/dist/commands.js +272 -0
  6. package/dist/context/project.d.ts +78 -0
  7. package/dist/context/project.js +202 -0
  8. package/dist/doctor/fixes.d.ts +31 -0
  9. package/dist/doctor/fixes.js +82 -0
  10. package/dist/doctor/rules.d.ts +7 -0
  11. package/dist/doctor/rules.js +230 -0
  12. package/dist/doctor/run.d.ts +9 -0
  13. package/dist/doctor/run.js +16 -0
  14. package/dist/doctor/types.d.ts +27 -0
  15. package/dist/doctor/types.js +3 -0
  16. package/dist/index.d.ts +35 -791
  17. package/dist/index.js +40 -2389
  18. package/dist/make/fields.d.ts +35 -0
  19. package/dist/make/fields.js +139 -0
  20. package/dist/make/make.d.ts +14 -0
  21. package/dist/make/make.js +316 -0
  22. package/dist/make/openapi.d.ts +9 -0
  23. package/dist/make/openapi.js +36 -0
  24. package/dist/make/permissions.d.ts +13 -0
  25. package/dist/make/permissions.js +38 -0
  26. package/dist/make/relations.d.ts +23 -0
  27. package/dist/make/relations.js +62 -0
  28. package/dist/make/render.d.ts +4 -0
  29. package/dist/make/render.js +66 -0
  30. package/dist/make/repository.d.ts +16 -0
  31. package/dist/make/repository.js +176 -0
  32. package/dist/make/schema.d.ts +22 -0
  33. package/dist/make/schema.js +54 -0
  34. package/dist/make/types.d.ts +77 -0
  35. package/dist/make/types.js +1 -0
  36. package/dist/make/verify.d.ts +12 -0
  37. package/dist/make/verify.js +19 -0
  38. package/dist/make/wire.d.ts +23 -0
  39. package/dist/make/wire.js +76 -0
  40. package/dist/plan/context.d.ts +8 -0
  41. package/dist/plan/context.js +39 -0
  42. package/dist/plan/knowledge.d.ts +9 -0
  43. package/dist/plan/knowledge.js +50 -0
  44. package/dist/plan/plan.d.ts +15 -0
  45. package/dist/plan/plan.js +146 -0
  46. package/dist/plan/render.d.ts +4 -0
  47. package/dist/plan/render.js +55 -0
  48. package/dist/plan/types.d.ts +45 -0
  49. package/dist/plan/types.js +1 -0
  50. package/dist/provider/anthropic.d.ts +26 -0
  51. package/dist/provider/anthropic.js +72 -0
  52. package/dist/provider/factory.d.ts +27 -0
  53. package/dist/provider/factory.js +58 -0
  54. package/dist/provider/http.d.ts +24 -0
  55. package/dist/provider/http.js +36 -0
  56. package/dist/provider/ollama.d.ts +22 -0
  57. package/dist/provider/ollama.js +42 -0
  58. package/dist/provider/openai.d.ts +48 -0
  59. package/dist/provider/openai.js +94 -0
  60. package/dist/provider/sse.d.ts +28 -0
  61. package/dist/provider/sse.js +57 -0
  62. package/dist/provider/types.d.ts +49 -0
  63. package/dist/provider/types.js +9 -0
  64. package/dist/render.d.ts +10 -0
  65. package/dist/render.js +70 -0
  66. package/dist/review/knowledge.d.ts +6 -0
  67. package/dist/review/knowledge.js +25 -0
  68. package/dist/review/render.d.ts +4 -0
  69. package/dist/review/render.js +12 -0
  70. package/dist/review/review.d.ts +17 -0
  71. package/dist/review/review.js +80 -0
  72. package/dist/review/types.d.ts +12 -0
  73. package/dist/review/types.js +1 -0
  74. package/package.json +10 -11
@@ -0,0 +1,35 @@
1
+ import type { PlanField } from '../plan/types.js';
2
+ export type CanonicalType = 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Json';
3
+ /** Map a loose plan type (`'string'`, `'DateTime'`, `'number'`…) to a canonical type. */
4
+ export declare function canonicalType(raw: string): CanonicalType;
5
+ /** Prisma column type — identical to the canonical name. */
6
+ export declare function prismaType(t: CanonicalType): string;
7
+ /**
8
+ * Zod validator for a field. On input (`create`) a `DateTime` coerces from a
9
+ * date/ISO string to a `Date` (so Prisma accepts it — a bare `z.string()` lets a
10
+ * non-ISO value like `"1990-05-01"` through to Prisma and 500). On output it's an
11
+ * ISO string, matching the mapper.
12
+ */
13
+ export declare function zodValidator(t: CanonicalType, create: boolean): string;
14
+ /** Zod validator for a field, honoring an enum (`z.enum([...])`) over its base type. */
15
+ export declare function zodForField(field: PlanField, create: boolean): string;
16
+ /** The entity's own fields, minus reserved/base columns and duplicates. */
17
+ export declare function domainFields(fields: PlanField[]): PlanField[];
18
+ /**
19
+ * Inject domain fields (and `tenantId` + `@@index` when tenant-scoped) into a
20
+ * generated Prisma model snippet, right after its `name` column. Returns
21
+ * `injected: false` if the expected anchor isn't found — the scaffold still lands
22
+ * unchanged and the caller reports it.
23
+ */
24
+ export declare function injectPrismaFields(content: string, fields: PlanField[], tenantScoped: boolean, removeName?: boolean): {
25
+ content: string;
26
+ injected: boolean;
27
+ };
28
+ /**
29
+ * Inject domain fields into a generated Zod schema file — into both the entity
30
+ * schema (`<Name>Schema`) and the create schema (`Create<Name>Schema`).
31
+ */
32
+ export declare function injectZodFields(content: string, fields: PlanField[], removeName?: boolean): {
33
+ content: string;
34
+ injected: boolean;
35
+ };
@@ -0,0 +1,139 @@
1
+ /** Columns the generator already emits — never re-inject these. */
2
+ const RESERVED = new Set(['id', 'name', 'createdat', 'updatedat', 'deletedat', 'tenantid']);
3
+ /** Map a loose plan type (`'string'`, `'DateTime'`, `'number'`…) to a canonical type. */
4
+ export function canonicalType(raw) {
5
+ switch (raw.trim().toLowerCase()) {
6
+ case 'int':
7
+ case 'integer':
8
+ case 'number':
9
+ case 'bigint':
10
+ return 'Int';
11
+ case 'float':
12
+ case 'decimal':
13
+ case 'double':
14
+ return 'Float';
15
+ case 'bool':
16
+ case 'boolean':
17
+ return 'Boolean';
18
+ case 'date':
19
+ case 'datetime':
20
+ case 'timestamp':
21
+ return 'DateTime';
22
+ case 'json':
23
+ case 'object':
24
+ return 'Json';
25
+ default:
26
+ return 'String';
27
+ }
28
+ }
29
+ /** Prisma column type — identical to the canonical name. */
30
+ export function prismaType(t) {
31
+ return t;
32
+ }
33
+ /**
34
+ * Zod validator for a field. On input (`create`) a `DateTime` coerces from a
35
+ * date/ISO string to a `Date` (so Prisma accepts it — a bare `z.string()` lets a
36
+ * non-ISO value like `"1990-05-01"` through to Prisma and 500). On output it's an
37
+ * ISO string, matching the mapper.
38
+ */
39
+ export function zodValidator(t, create) {
40
+ switch (t) {
41
+ case 'Int':
42
+ return 'z.number().int()';
43
+ case 'Float':
44
+ return 'z.number()';
45
+ case 'Boolean':
46
+ return 'z.boolean()';
47
+ case 'DateTime':
48
+ return create ? 'z.coerce.date()' : 'z.string()';
49
+ case 'Json':
50
+ return 'z.unknown()';
51
+ case 'String':
52
+ return create ? 'z.string().min(1)' : 'z.string()';
53
+ }
54
+ }
55
+ function usable(field) {
56
+ return field.name.trim() !== '' && !RESERVED.has(field.name.toLowerCase());
57
+ }
58
+ /** Zod validator for a field, honoring an enum (`z.enum([...])`) over its base type. */
59
+ export function zodForField(field, create) {
60
+ if (field.enum && field.enum.length > 0) {
61
+ return `z.enum([${field.enum.map((v) => `'${v}'`).join(', ')}])`;
62
+ }
63
+ return zodValidator(canonicalType(field.type), create);
64
+ }
65
+ /** The entity's own fields, minus reserved/base columns and duplicates. */
66
+ export function domainFields(fields) {
67
+ const seen = new Set();
68
+ const out = [];
69
+ for (const field of fields) {
70
+ const key = field.name.toLowerCase();
71
+ if (!usable(field) || seen.has(key))
72
+ continue;
73
+ seen.add(key);
74
+ out.push(field);
75
+ }
76
+ return out;
77
+ }
78
+ function hasPrismaField(content, name) {
79
+ return new RegExp(`^[ \\t]*${name}[ \\t]+`, 'm').test(content);
80
+ }
81
+ /**
82
+ * Inject domain fields (and `tenantId` + `@@index` when tenant-scoped) into a
83
+ * generated Prisma model snippet, right after its `name` column. Returns
84
+ * `injected: false` if the expected anchor isn't found — the scaffold still lands
85
+ * unchanged and the caller reports it.
86
+ */
87
+ export function injectPrismaFields(content, fields, tenantScoped, removeName = false) {
88
+ const nameAnchor = /^([ \t]*)name[ \t]+String.*$/m;
89
+ if (!nameAnchor.test(content))
90
+ return { content, injected: false };
91
+ const extras = [];
92
+ if (tenantScoped && !hasPrismaField(content, 'tenantId'))
93
+ extras.push(' tenantId String');
94
+ for (const field of domainFields(fields)) {
95
+ if (hasPrismaField(content, field.name))
96
+ continue;
97
+ extras.push(` ${field.name} ${prismaType(canonicalType(field.type))}`);
98
+ }
99
+ if (extras.length === 0)
100
+ return { content, injected: false };
101
+ let out = content.replace(nameAnchor, (line) => `${line}\n${extras.join('\n')}`);
102
+ if (tenantScoped)
103
+ out = out.replace(/\n\}\s*$/, `\n\n @@index([tenantId])\n}\n`);
104
+ // Drop the generator's base `name` column when the entity supplies its own
105
+ // fields and none is literally `name` (avoids a spurious empty column).
106
+ if (removeName)
107
+ out = out.replace(/^[ \t]*name[ \t]+String\b.*\n/m, '');
108
+ return { content: out, injected: true };
109
+ }
110
+ /**
111
+ * Inject domain fields into a generated Zod schema file — into both the entity
112
+ * schema (`<Name>Schema`) and the create schema (`Create<Name>Schema`).
113
+ */
114
+ export function injectZodFields(content, fields, removeName = false) {
115
+ const usableFields = domainFields(fields);
116
+ if (usableFields.length === 0)
117
+ return { content, injected: false };
118
+ let out = content;
119
+ let injected = false;
120
+ const entityAnchor = /^([ \t]*)name: z\.string\(\),.*$/m;
121
+ const entityLines = usableFields.map((f) => ` ${f.name}: ${zodForField(f, false)},`);
122
+ if (entityAnchor.test(out)) {
123
+ out = out.replace(entityAnchor, (line) => `${line}\n${entityLines.join('\n')}`);
124
+ injected = true;
125
+ }
126
+ const createAnchor = /^([ \t]*)name: z\.string\(\)\.min\(1\),.*$/m;
127
+ const createLines = usableFields.map((f) => ` ${f.name}: ${zodForField(f, true)},`);
128
+ if (createAnchor.test(out)) {
129
+ out = out.replace(createAnchor, (line) => `${line}\n${createLines.join('\n')}`);
130
+ injected = true;
131
+ }
132
+ // Drop the base `name` from both schemas when the entity has its own fields
133
+ // and none is literally `name`.
134
+ if (removeName) {
135
+ out = out.replace(/^[ \t]*name: z\.string\(\),.*\n/m, '');
136
+ out = out.replace(/^[ \t]*name: z\.string\(\)\.min\(1\),.*\n/m, '');
137
+ }
138
+ return { content: out, injected };
139
+ }
@@ -0,0 +1,14 @@
1
+ import type { ProjectContext } from '../context/project.js';
2
+ import type { ArchitecturePlan } from '../plan/types.js';
3
+ import type { MakeOptions, MakeResult } from './types.js';
4
+ /**
5
+ * Execute an {@link ArchitecturePlan}: scaffold each entity via the official
6
+ * generator, inject the plan's domain fields, write + auto-wire, then run a
7
+ * deterministic review gate. Read-only when `dryRun` is set.
8
+ *
9
+ * Hybrid by design (the locked decision): the generator produces the
10
+ * on-convention vertical; we only augment it with fields the plan already
11
+ * decided. No destructive operations — never overwrites without `force`, never
12
+ * touches the database, never deletes.
13
+ */
14
+ export declare function runMake(ctx: ProjectContext, plan: ArchitecturePlan, options?: MakeOptions): Promise<MakeResult>;
@@ -0,0 +1,316 @@
1
+ import { FileExistsError, generateResource, names, registerResourceInApp, writeGenerated, } from '@basaltkit/generator';
2
+ import { domainFields, injectPrismaFields, injectZodFields } from './fields.js';
3
+ import { externalRelationTargets, injectPrismaRelations, inverseRelationLines, relationFieldLines, relationForeignKeys, } from './relations.js';
4
+ import { injectOpenApiMeta } from './openapi.js';
5
+ import { renderPermissionsFile } from './permissions.js';
6
+ import { renderPrismaRepository } from './repository.js';
7
+ import { extractModelBlock, mergeModelsIntoSchema, readSchema, runPrismaPush, writeSchema, } from './schema.js';
8
+ import { injectAuditPlugin, injectAuditService, injectPermissionGuards } from './wire.js';
9
+ /**
10
+ * Execute an {@link ArchitecturePlan}: scaffold each entity via the official
11
+ * generator, inject the plan's domain fields, write + auto-wire, then run a
12
+ * deterministic review gate. Read-only when `dryRun` is set.
13
+ *
14
+ * Hybrid by design (the locked decision): the generator produces the
15
+ * on-convention vertical; we only augment it with fields the plan already
16
+ * decided. No destructive operations — never overwrites without `force`, never
17
+ * touches the database, never deletes.
18
+ */
19
+ export async function runMake(ctx, plan, options = {}) {
20
+ const entities = plan.entities.filter((e) => e.name.trim() !== '');
21
+ if (entities.length === 0) {
22
+ throw new Error('ai:make — the plan has no entity to generate.');
23
+ }
24
+ const baseDir = options.baseDir ?? process.cwd();
25
+ const prismaDefault = ctx.stack.orm === 'prisma';
26
+ const resources = [];
27
+ for (const entity of entities) {
28
+ const gen = findGeneratorCommand(plan, entity.name);
29
+ const prisma = options.prisma ?? gen?.prisma ?? prismaDefault;
30
+ const softDelete = options.softDelete ?? gen?.softDelete ?? false;
31
+ const generated = generateResource(entity.name, { prisma, softDelete });
32
+ const { files, augmented, guarded, audited } = augmentFiles(generated, entity, plan, ctx, { prisma, softDelete });
33
+ const build = {
34
+ name: entity.name,
35
+ prisma,
36
+ softDelete,
37
+ files,
38
+ augmented,
39
+ guarded,
40
+ audited,
41
+ written: [],
42
+ registered: false,
43
+ };
44
+ if (!options.dryRun) {
45
+ try {
46
+ build.written = await writeGenerated(files, {
47
+ baseDir,
48
+ ...(options.force ? { force: true } : {}),
49
+ });
50
+ const reg = await registerResourceInApp(entity.name, { baseDir });
51
+ build.registered = reg.registered;
52
+ if (!reg.registered && reg.reason && reg.reason !== 'already registered') {
53
+ build.note = `not auto-wired: ${reg.reason}`;
54
+ }
55
+ }
56
+ catch (error) {
57
+ if (error instanceof FileExistsError) {
58
+ build.note = error.message.split('\n')[0] ?? error.message;
59
+ }
60
+ else {
61
+ throw error;
62
+ }
63
+ }
64
+ }
65
+ resources.push(build);
66
+ }
67
+ // Merge the generated models into prisma/schema.prisma (idempotent), and
68
+ // optionally run `prisma db push` — removing the biggest manual step.
69
+ const { schema, migration } = await mergeSchema(resources, baseDir, options);
70
+ const followUps = buildFollowUps(plan, resources, schema, migration);
71
+ const review = reviewBuild(ctx, plan, resources, schema, migration);
72
+ return {
73
+ request: plan.request,
74
+ dryRun: options.dryRun === true,
75
+ resources,
76
+ ...(schema ? { schema } : {}),
77
+ ...(migration ? { migration } : {}),
78
+ followUps,
79
+ review,
80
+ };
81
+ }
82
+ async function mergeSchema(resources, baseDir, options) {
83
+ const prismaResources = resources.filter((r) => r.prisma);
84
+ if (prismaResources.length === 0)
85
+ return { schema: undefined, migration: undefined };
86
+ const schemaPath = options.schemaPath ?? 'prisma/schema.prisma';
87
+ const blocks = prismaResources
88
+ .map((r) => r.files.find((f) => f.path.endsWith('.prisma')))
89
+ .map((f) => (f ? extractModelBlock(f.content) : null))
90
+ .filter((b) => b !== null);
91
+ const existing = await readSchema(baseDir, schemaPath);
92
+ if (existing === null) {
93
+ return { schema: { path: schemaPath, found: false, merged: [], skipped: [], written: false }, migration: undefined };
94
+ }
95
+ const outcome = mergeModelsIntoSchema(existing, blocks);
96
+ const schema = {
97
+ path: schemaPath,
98
+ found: true,
99
+ merged: outcome.merged,
100
+ skipped: outcome.skipped,
101
+ written: false,
102
+ };
103
+ if (options.dryRun)
104
+ return { schema, migration: undefined };
105
+ if (outcome.merged.length > 0) {
106
+ await writeSchema(baseDir, schemaPath, outcome.content);
107
+ schema.written = true;
108
+ }
109
+ if (options.migrate && (schema.written || outcome.skipped.length > 0)) {
110
+ const push = await runPrismaPush(baseDir);
111
+ return { schema, migration: { ok: push.ok, output: push.output } };
112
+ }
113
+ return { schema, migration: undefined };
114
+ }
115
+ /** Find the plan's generator step for an entity and parse its flags — the plan is the source of truth. */
116
+ function findGeneratorCommand(plan, entityName) {
117
+ for (const step of plan.steps) {
118
+ if (step.kind !== 'generator' || !step.command)
119
+ continue;
120
+ const parsed = parseGeneratorCommand(step.command);
121
+ if (parsed && parsed.name.toLowerCase() === entityName.toLowerCase())
122
+ return parsed;
123
+ }
124
+ return null;
125
+ }
126
+ function parseGeneratorCommand(command) {
127
+ const match = /make:resource\s+(\w+)([^\n]*)/.exec(command);
128
+ if (!match || !match[1])
129
+ return null;
130
+ const rest = match[2] ?? '';
131
+ return { name: match[1], prisma: /--prisma\b/.test(rest), softDelete: /--soft-delete\b/.test(rest) };
132
+ }
133
+ function augmentFiles(files, entity, plan, ctx, opts) {
134
+ const tenantScoped = ctx.stack.tenancy && entity.tenantScoped;
135
+ // Domain fields plus the synthetic FK columns from belongs-to relations.
136
+ const effectiveFields = [...entity.fields, ...relationForeignKeys(entity)];
137
+ // Drop the generator's base `name` column when the entity has its own fields
138
+ // and none is literally `name` (else keep it as the label field).
139
+ const removeName = domainFields(effectiveFields).length > 0 && !effectiveFields.some((f) => f.name.toLowerCase() === 'name');
140
+ const keepName = !removeName;
141
+ // Per-entity permission namespace — so each entity in a multi-entity plan is
142
+ // guarded by its own permission, not a shared one from permissions[0].
143
+ const guardPrefix = names(entity.name).pluralKebab;
144
+ const wireGuards = ctx.stack.rbac && plan.permissions.length > 0;
145
+ const wireAudit = ctx.stack.audit && plan.auditEvents.length > 0;
146
+ let augmented = false;
147
+ let guarded = false;
148
+ let audited = false;
149
+ const out = files.map((file) => {
150
+ if (file.path.endsWith('.prisma')) {
151
+ const withFields = injectPrismaFields(file.content, effectiveFields, tenantScoped, removeName);
152
+ // Real Prisma relations: FK column (above), @relation field + inverse fields.
153
+ const withRelations = injectPrismaRelations(withFields.content, relationFieldLines(entity), inverseRelationLines(entity.name, plan.entities));
154
+ augmented = augmented || withFields.injected || withRelations.injected;
155
+ return { ...file, content: withRelations.content };
156
+ }
157
+ if (file.path.endsWith('.schema.ts')) {
158
+ const result = injectZodFields(file.content, effectiveFields, removeName);
159
+ augmented = augmented || result.injected;
160
+ return { ...file, content: result.content };
161
+ }
162
+ if (file.path.endsWith('.repository.ts') && opts.prisma) {
163
+ // Replace the generator's repository with a complete mapper (all domain
164
+ // fields + FK columns) + explicit tenant scoping when tenant-scoped.
165
+ const content = renderPrismaRepository(entity.name, effectiveFields, {
166
+ softDelete: opts.softDelete,
167
+ tenantScoped,
168
+ keepName,
169
+ });
170
+ return { ...file, content };
171
+ }
172
+ if (file.path.endsWith('.routes.ts')) {
173
+ let content = file.content;
174
+ if (wireGuards) {
175
+ const result = injectPermissionGuards(content, guardPrefix);
176
+ content = result.content;
177
+ guarded = guarded || result.injected;
178
+ }
179
+ // OpenAPI enrichment: summary + tags on every route (merges with the guard meta).
180
+ content = injectOpenApiMeta(content, entity.name).content;
181
+ return { ...file, content };
182
+ }
183
+ if (file.path.endsWith('.service.ts') && wireAudit) {
184
+ const result = injectAuditService(file.content, entity.name, plan.auditEvents);
185
+ audited = audited || result.injected;
186
+ return { ...file, content: result.content };
187
+ }
188
+ if (file.path.endsWith('.plugin.ts') && wireAudit) {
189
+ const result = injectAuditPlugin(file.content, entity.name);
190
+ return { ...file, content: result.content };
191
+ }
192
+ return file;
193
+ });
194
+ // Declare the resource's permissions + a grant helper (closes the RBAC loop).
195
+ if (guarded) {
196
+ const kebab = names(entity.name).kebab;
197
+ out.push({ path: `src/modules/${kebab}/${kebab}.permissions.ts`, content: renderPermissionsFile(entity.name) });
198
+ }
199
+ return { files: out, augmented, guarded, audited };
200
+ }
201
+ function buildFollowUps(plan, resources, schema, migration) {
202
+ const followUps = [];
203
+ const prismaResources = resources.filter((r) => r.prisma);
204
+ if (prismaResources.length > 0) {
205
+ if (!schema?.found) {
206
+ const list = prismaResources.map((r) => `src/modules/${kebab(r.name)}/${kebab(r.name)}.prisma`).join(', ');
207
+ followUps.push(`Add the generated model(s) (${list}) to prisma/schema.prisma, then run \`npx prisma db push\` and restart the server.`);
208
+ }
209
+ else if (migration) {
210
+ followUps.push(migration.ok
211
+ ? 'Restart the server so it loads the regenerated Prisma client.'
212
+ : '`prisma db push` failed — check the output, fix it, and re-run `npx prisma db push`.');
213
+ }
214
+ else {
215
+ followUps.push('Run `npx prisma db push` (or re-run `ai:make --migrate`) to create the table(s) + regenerate the client, then restart the server.');
216
+ }
217
+ }
218
+ const external = externalRelationTargets(plan.entities);
219
+ if (external.length > 0) {
220
+ followUps.push(`Relations reference model(s) not in this plan (${external.join(', ')}). Add the inverse field ` +
221
+ '(e.g. `items <This>[]`) to those existing models in schema.prisma so Prisma can validate the relation.');
222
+ }
223
+ const guardedResources = resources.filter((r) => r.guarded);
224
+ const audited = resources.some((r) => r.audited);
225
+ if (plan.permissions.length > 0 && guardedResources.length === 0) {
226
+ followUps.push(`Register RBAC permissions (${plan.permissions.join(', ')}) and guard the routes.`);
227
+ }
228
+ else if (guardedResources.length > 0) {
229
+ const helpers = guardedResources.map((r) => `grant${names(r.name).pascal}Permissions(store, 'admin')`).join(', ');
230
+ followUps.push(`Grant permissions to your roles during seed/setup — call ${helpers} (declared in each module's *.permissions.ts).`);
231
+ }
232
+ if (plan.auditEvents.length > 0 && !audited) {
233
+ followUps.push(`Emit audit events (${plan.auditEvents.join(', ')}) on create/update/delete.`);
234
+ }
235
+ return followUps;
236
+ }
237
+ function reviewBuild(ctx, plan, resources, schema, migration) {
238
+ const items = [];
239
+ // Tenant isolation — the gate the spec cares most about.
240
+ if (ctx.stack.tenancy) {
241
+ const tenantEntities = plan.entities.filter((e) => e.tenantScoped);
242
+ if (tenantEntities.length === 0) {
243
+ items.push({ label: 'Tenant isolation', status: 'warn', detail: 'no entity marked tenant-scoped — confirm that is intended' });
244
+ }
245
+ else {
246
+ const missing = resources.filter((r) => plan.entities.find((e) => e.name === r.name)?.tenantScoped && !hasTenantId(r));
247
+ items.push(missing.length === 0
248
+ ? { label: 'Tenant isolation', status: 'pass', detail: 'tenantId present on tenant-scoped models' }
249
+ : { label: 'Tenant isolation', status: 'fail', detail: `missing tenantId: ${missing.map((r) => r.name).join(', ')}` });
250
+ }
251
+ }
252
+ else {
253
+ items.push({ label: 'Tenant isolation', status: 'pass', detail: 'tenancy is off — not applicable' });
254
+ }
255
+ // Validation + routes.
256
+ const hasRoutes = resources.every((r) => r.files.some((f) => f.path.endsWith('.routes.ts')));
257
+ const hasSchema = resources.every((r) => r.files.some((f) => f.path.endsWith('.schema.ts')));
258
+ items.push(hasRoutes && hasSchema
259
+ ? { label: 'Validation & routes', status: 'pass', detail: 'Zod schema + typed routes generated' }
260
+ : { label: 'Validation & routes', status: 'fail', detail: 'schema or routes missing' });
261
+ // Tests.
262
+ const hasTests = resources.every((r) => r.files.some((f) => f.path.endsWith('.test.ts')));
263
+ items.push(hasTests
264
+ ? { label: 'Tests', status: 'pass', detail: 'a test file was generated per resource' }
265
+ : { label: 'Tests', status: 'fail', detail: 'no test generated' });
266
+ // Permissions — auto-wired as route meta.can guards when RBAC is enabled.
267
+ if (plan.permissions.length > 0) {
268
+ const guarded = resources.some((r) => r.guarded);
269
+ items.push(guarded
270
+ ? { label: 'Permissions', status: 'pass', detail: 'routes guarded + permissions declared in *.permissions.ts (grant via the helper)' }
271
+ : {
272
+ label: 'Permissions',
273
+ status: 'warn',
274
+ detail: ctx.stack.rbac ? 'planned but not auto-wired' : 'RBAC (@basaltkit/permissions) not enabled — wire manually',
275
+ });
276
+ }
277
+ // Audit — auto-wired into the service/plugin when audit is enabled.
278
+ if (plan.auditEvents.length > 0) {
279
+ const audited = resources.some((r) => r.audited);
280
+ items.push(audited
281
+ ? { label: 'Audit', status: 'pass', detail: 'AUDIT.record() wired into create/update/delete' }
282
+ : {
283
+ label: 'Audit',
284
+ status: 'warn',
285
+ detail: ctx.stack.audit ? 'planned but not auto-wired' : 'audit (@basaltkit/audit) not enabled — wire manually',
286
+ });
287
+ }
288
+ // Migration — auto-merged into schema.prisma; `--migrate` runs prisma db push.
289
+ if (resources.some((r) => r.prisma)) {
290
+ if (migration?.ok) {
291
+ items.push({ label: 'Migration', status: 'pass', detail: 'model merged into schema.prisma + prisma db push ran' });
292
+ }
293
+ else if (migration) {
294
+ items.push({ label: 'Migration', status: 'fail', detail: 'prisma db push failed — see output' });
295
+ }
296
+ else if (schema?.found) {
297
+ items.push({ label: 'Migration', status: 'warn', detail: 'model merged into schema.prisma — run npx prisma db push' });
298
+ }
299
+ else {
300
+ items.push({ label: 'Migration', status: 'warn', detail: 'add the model to schema.prisma + run npx prisma db push' });
301
+ }
302
+ }
303
+ return { items, ok: !items.some((i) => i.status === 'fail') };
304
+ }
305
+ function hasTenantId(resource) {
306
+ const model = resource.files.find((f) => f.path.endsWith('.prisma'));
307
+ if (!model)
308
+ return false;
309
+ return /^[ \t]*tenantId[ \t]+/m.test(model.content);
310
+ }
311
+ function kebab(name) {
312
+ return name
313
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
314
+ .replace(/[\s_]+/g, '-')
315
+ .toLowerCase();
316
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * OpenAPI enrichment (spec §14): add a `summary` and `tags` to each generated
3
+ * route's `meta`, merging into an existing `meta` (e.g. the `can` guard) or
4
+ * creating one. `@basaltkit/http` renders these into the OpenAPI document.
5
+ */
6
+ export declare function injectOpenApiMeta(content: string, entityName: string): {
7
+ content: string;
8
+ injected: boolean;
9
+ };
@@ -0,0 +1,36 @@
1
+ import { names } from '@basaltkit/generator';
2
+ /** Human summary for a generated CRUD route, from its method + url. */
3
+ function summaryFor(method, url, singular, plural) {
4
+ switch (method.toUpperCase()) {
5
+ case 'GET':
6
+ return url.includes(':id') ? `Get a ${singular}` : `List ${plural}`;
7
+ case 'POST':
8
+ return url.endsWith('/restore') ? `Restore a ${singular}` : `Create a ${singular}`;
9
+ case 'PATCH':
10
+ case 'PUT':
11
+ return `Update a ${singular}`;
12
+ case 'DELETE':
13
+ return `Delete a ${singular}`;
14
+ default:
15
+ return `${singular} operation`;
16
+ }
17
+ }
18
+ /**
19
+ * OpenAPI enrichment (spec §14): add a `summary` and `tags` to each generated
20
+ * route's `meta`, merging into an existing `meta` (e.g. the `can` guard) or
21
+ * creating one. `@basaltkit/http` renders these into the OpenAPI document.
22
+ */
23
+ export function injectOpenApiMeta(content, entityName) {
24
+ const n = names(entityName);
25
+ const singular = n.kebab.replace(/-/g, ' ');
26
+ const plural = n.pluralKebab.replace(/-/g, ' ');
27
+ const tag = n.pascal;
28
+ let injected = false;
29
+ const out = content.replace(/route\(\{\s*\n(\s*)method: '(\w+)',\s*\n\s*url: '([^']*)',(?:\s*\n\s*meta: \{([^}]*)\},)?/g, (_match, indent, method, url, metaInner) => {
30
+ const extra = `summary: '${summaryFor(method, url, singular, plural)}', tags: ['${tag}']`;
31
+ const meta = metaInner !== undefined ? `{ ${metaInner.trim()}, ${extra} }` : `{ ${extra} }`;
32
+ injected = true;
33
+ return `route({\n${indent}method: '${method}',\n${indent}url: '${url}',\n${indent}meta: ${meta},`;
34
+ });
35
+ return { content: out, injected };
36
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The RBAC permissions for a resource — the exact `<plural>.<action>` strings its
3
+ * route guards check on (F5 derives guards from the entity name, so declaring
4
+ * them here keeps a single source of truth that can't drift from the guards).
5
+ */
6
+ export declare function permissionList(name: string): string[];
7
+ /**
8
+ * Generate `<kebab>.permissions.ts`: the permission constants plus a
9
+ * `grant<Name>Permissions(store, role)` helper — so registering RBAC for the
10
+ * resource is one call in the app's seed/setup, closing the loop F4 opened
11
+ * (guards without a way to grant them).
12
+ */
13
+ export declare function renderPermissionsFile(name: string): string;
@@ -0,0 +1,38 @@
1
+ import { names } from '@basaltkit/generator';
2
+ /**
3
+ * The RBAC permissions for a resource — the exact `<plural>.<action>` strings its
4
+ * route guards check on (F5 derives guards from the entity name, so declaring
5
+ * them here keeps a single source of truth that can't drift from the guards).
6
+ */
7
+ export function permissionList(name) {
8
+ const prefix = names(name).pluralKebab;
9
+ return ['view', 'create', 'update', 'delete'].map((action) => `${prefix}.${action}`);
10
+ }
11
+ /**
12
+ * Generate `<kebab>.permissions.ts`: the permission constants plus a
13
+ * `grant<Name>Permissions(store, role)` helper — so registering RBAC for the
14
+ * resource is one call in the app's seed/setup, closing the loop F4 opened
15
+ * (guards without a way to grant them).
16
+ */
17
+ export function renderPermissionsFile(name) {
18
+ const n = names(name);
19
+ const listLines = permissionList(name)
20
+ .map((p) => ` '${p}',`)
21
+ .join('\n');
22
+ return `import { GLOBAL_SCOPE, type AccessStore } from '@basaltkit/permissions'
23
+
24
+ /** RBAC permissions for the ${n.pascal} resource — the ones its routes guard on. */
25
+ export const ${n.constant}_PERMISSIONS = [
26
+ ${listLines}
27
+ ] as const
28
+
29
+ /** Grant every ${n.pascal} permission to a role (default scope: global). */
30
+ export async function grant${n.pascal}Permissions(
31
+ store: AccessStore,
32
+ role: string,
33
+ scope: string = GLOBAL_SCOPE,
34
+ ): Promise<void> {
35
+ await store.grantToRole(role, [...${n.constant}_PERMISSIONS], scope)
36
+ }
37
+ `;
38
+ }
@@ -0,0 +1,23 @@
1
+ import type { PlanEntity, PlanField, PlanRelation } from '../plan/types.js';
2
+ /**
3
+ * Real Prisma relations (Fase 6). A belongs-to relation on an entity generates:
4
+ * a `<name>Id` FK column, a `<name> <Model> @relation(...)` field, and — on the
5
+ * related model — an inverse `<plural> <This>[]` field. Both sides are required
6
+ * for Prisma to validate, so a relation to a model outside the plan is surfaced
7
+ * as a follow-up (the user adds the inverse field to their existing model).
8
+ */
9
+ /** FK column name for a relation, e.g. `paciente` → `pacienteId`. */
10
+ export declare function foreignKey(relation: PlanRelation): string;
11
+ /** Synthetic FK fields (String) for an entity's relations — feed the Zod schema, mapper and columns. */
12
+ export declare function relationForeignKeys(entity: PlanEntity): PlanField[];
13
+ /** Prisma relation field lines for the entity's belongs-to relations. */
14
+ export declare function relationFieldLines(entity: PlanEntity): string[];
15
+ /** Inverse `hasMany` lines to add to `modelName`, from every other entity that belongs to it. */
16
+ export declare function inverseRelationLines(modelName: string, allEntities: PlanEntity[]): string[];
17
+ /** Insert relation + inverse lines into a generated Prisma model, before its closing brace. */
18
+ export declare function injectPrismaRelations(content: string, relationLines: string[], inverseLines: string[]): {
19
+ content: string;
20
+ injected: boolean;
21
+ };
22
+ /** Relation targets that aren't generated in this plan (need a manual inverse field). */
23
+ export declare function externalRelationTargets(entities: PlanEntity[]): string[];