@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.
- package/README.md +4 -3
- package/dist/analyze/run.d.ts +19 -0
- package/dist/analyze/run.js +58 -0
- package/dist/commands.d.ts +19 -0
- package/dist/commands.js +272 -0
- package/dist/context/project.d.ts +78 -0
- package/dist/context/project.js +202 -0
- package/dist/doctor/fixes.d.ts +31 -0
- package/dist/doctor/fixes.js +82 -0
- package/dist/doctor/rules.d.ts +7 -0
- package/dist/doctor/rules.js +230 -0
- package/dist/doctor/run.d.ts +9 -0
- package/dist/doctor/run.js +16 -0
- package/dist/doctor/types.d.ts +27 -0
- package/dist/doctor/types.js +3 -0
- package/dist/index.d.ts +35 -791
- package/dist/index.js +40 -2389
- package/dist/make/fields.d.ts +35 -0
- package/dist/make/fields.js +139 -0
- package/dist/make/make.d.ts +14 -0
- package/dist/make/make.js +316 -0
- package/dist/make/openapi.d.ts +9 -0
- package/dist/make/openapi.js +36 -0
- package/dist/make/permissions.d.ts +13 -0
- package/dist/make/permissions.js +38 -0
- package/dist/make/relations.d.ts +23 -0
- package/dist/make/relations.js +62 -0
- package/dist/make/render.d.ts +4 -0
- package/dist/make/render.js +66 -0
- package/dist/make/repository.d.ts +16 -0
- package/dist/make/repository.js +176 -0
- package/dist/make/schema.d.ts +22 -0
- package/dist/make/schema.js +54 -0
- package/dist/make/types.d.ts +77 -0
- package/dist/make/types.js +1 -0
- package/dist/make/verify.d.ts +12 -0
- package/dist/make/verify.js +19 -0
- package/dist/make/wire.d.ts +23 -0
- package/dist/make/wire.js +76 -0
- package/dist/plan/context.d.ts +8 -0
- package/dist/plan/context.js +39 -0
- package/dist/plan/knowledge.d.ts +9 -0
- package/dist/plan/knowledge.js +50 -0
- package/dist/plan/plan.d.ts +15 -0
- package/dist/plan/plan.js +146 -0
- package/dist/plan/render.d.ts +4 -0
- package/dist/plan/render.js +55 -0
- package/dist/plan/types.d.ts +45 -0
- package/dist/plan/types.js +1 -0
- package/dist/provider/anthropic.d.ts +26 -0
- package/dist/provider/anthropic.js +72 -0
- package/dist/provider/factory.d.ts +27 -0
- package/dist/provider/factory.js +58 -0
- package/dist/provider/http.d.ts +24 -0
- package/dist/provider/http.js +36 -0
- package/dist/provider/ollama.d.ts +22 -0
- package/dist/provider/ollama.js +42 -0
- package/dist/provider/openai.d.ts +48 -0
- package/dist/provider/openai.js +94 -0
- package/dist/provider/sse.d.ts +28 -0
- package/dist/provider/sse.js +57 -0
- package/dist/provider/types.d.ts +49 -0
- package/dist/provider/types.js +9 -0
- package/dist/render.d.ts +10 -0
- package/dist/render.js +70 -0
- package/dist/review/knowledge.d.ts +6 -0
- package/dist/review/knowledge.js +25 -0
- package/dist/review/render.d.ts +4 -0
- package/dist/review/render.js +12 -0
- package/dist/review/review.d.ts +17 -0
- package/dist/review/review.js +80 -0
- package/dist/review/types.d.ts +12 -0
- package/dist/review/types.js +1 -0
- 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[];
|