@ultimat3/cli 27.6.1 → 27.8.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/CLAUDE.md +1 -1
- package/package.json +28 -28
- package/src/admin-registration.ts +15 -14
- package/src/api-registration.ts +48 -47
- package/src/app-manifest.ts +15 -0
- package/src/cmd-generate.ts +65 -49
- package/src/dev-boot.ts +10 -1
- package/src/generate-disk.ts +38 -0
- package/src/generate-files.ts +6 -1
- package/src/generate-follow-ups.ts +64 -0
- package/src/generate-grant-findings.ts +8 -6
- package/src/generate-grants.ts +79 -52
- package/src/generate-kinds.ts +7 -2
- package/src/generate-plural.ts +11 -0
- package/src/generate-sibling.ts +34 -0
- package/src/generated-imports.ts +9 -6
- package/src/handle-registration.ts +71 -29
- package/src/i18n-index.ts +30 -14
- package/src/isr-query-advice.ts +63 -0
- package/src/isr-query-guard.ts +49 -0
- package/src/mcp-host.ts +2 -1
- package/src/messages.ts +4 -0
- package/src/role-sync-bus-wait.ts +66 -0
- package/src/role-sync.ts +6 -2
- package/src/runtime-bus.ts +198 -0
- package/src/runtime-cache.ts +158 -10
- package/src/runtime-isr-outcome.ts +60 -13
- package/src/runtime-isr.ts +23 -0
- package/src/runtime-render.ts +4 -3
- package/src/runtime-services.ts +30 -37
- package/src/seo-routes.ts +114 -14
- package/src/serve-boot.ts +4 -1
- package/src/serve-types.ts +6 -0
- package/src/serve.ts +8 -1
- package/src/source-list-edit.ts +218 -0
- package/src/templates/entity-module.ts +29 -0
- package/src/templates/entity.ts +30 -14
- package/src/verify-checks.ts +5 -0
- package/src/workspace-dep-edit.ts +13 -10
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Everything `x g` edits OUTSIDE the files it creates, in the one order they have to happen: a
|
|
2
|
+
// declared permission granted to a role, a primitive listed in `defineApi`, an entity added to the
|
|
3
|
+
// typed handle, an `--admin` override wired, each new workspace edge declared, and every catalog
|
|
4
|
+
// made selectable. One function over one disk seam, so a real run and a dry run are the same code
|
|
5
|
+
// — and the dry run's list of touched files is what the run touches, not a second guess at it.
|
|
6
|
+
|
|
7
|
+
import { registerAdminResources } from './admin-registration';
|
|
8
|
+
import { registerGeneratedPrimitives } from './api-registration';
|
|
9
|
+
import type { GenerateDisk } from './generate-disk';
|
|
10
|
+
import { ungrantedByGenerator } from './generate-grant-findings';
|
|
11
|
+
import { grantGeneratedPermissions } from './generate-grants';
|
|
12
|
+
import { declareGeneratedImports } from './generated-imports';
|
|
13
|
+
import { registerGeneratedEntities } from './handle-registration';
|
|
14
|
+
import { syncI18nIndex } from './i18n-index';
|
|
15
|
+
import type { Finding } from './output';
|
|
16
|
+
import { CATALOG_ROOT } from './templates';
|
|
17
|
+
|
|
18
|
+
export interface FollowUps {
|
|
19
|
+
/** App-root-relative paths edited or created beyond the generator's own files, each once. */
|
|
20
|
+
readonly edited: readonly string[];
|
|
21
|
+
readonly findings: readonly Finding[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The locale each written catalog file is for: a dry run's catalogs are not on the disk yet. */
|
|
25
|
+
const writtenLocales = (written: readonly string[]): readonly string[] =>
|
|
26
|
+
written.flatMap((path) => {
|
|
27
|
+
const stem = path.startsWith(`${CATALOG_ROOT}/`) ? path.slice(CATALOG_ROOT.length + 1) : '';
|
|
28
|
+
return stem.endsWith('.json') && !stem.includes('/') ? [stem.slice(0, -'.json'.length)] : [];
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export async function followUpEdits(
|
|
32
|
+
root: string,
|
|
33
|
+
written: readonly string[],
|
|
34
|
+
dbModule: string | undefined,
|
|
35
|
+
disk: GenerateDisk,
|
|
36
|
+
): Promise<FollowUps> {
|
|
37
|
+
const handle = await registerGeneratedEntities(root, written, dbModule, disk);
|
|
38
|
+
const adminWiring = await registerAdminResources(root, written, disk);
|
|
39
|
+
const granted = await grantGeneratedPermissions(root, written, disk);
|
|
40
|
+
const listed = await registerGeneratedPrimitives(root, written, disk);
|
|
41
|
+
// Last of the manifest writers, because the others add imports too: every sibling workspace a
|
|
42
|
+
// written file imports, declared in the manifest it landed under.
|
|
43
|
+
const imports = await declareGeneratedImports(root, written, disk);
|
|
44
|
+
// A grant that edit could not place is said so, with the role each permission belongs to.
|
|
45
|
+
const ungranted = await ungrantedByGenerator(root, written, disk);
|
|
46
|
+
// A catalog on disk and a locale the app can select are two facts — see `syncI18nIndex`.
|
|
47
|
+
const indexSync =
|
|
48
|
+
written.length > 0
|
|
49
|
+
? await syncI18nIndex(root, disk, writtenLocales(written))
|
|
50
|
+
: { registered: true, findings: [] };
|
|
51
|
+
return {
|
|
52
|
+
edited: [
|
|
53
|
+
...new Set([
|
|
54
|
+
...granted,
|
|
55
|
+
...listed,
|
|
56
|
+
...handle.edited,
|
|
57
|
+
...adminWiring.edited,
|
|
58
|
+
...imports,
|
|
59
|
+
...(indexSync.edited ?? []),
|
|
60
|
+
]),
|
|
61
|
+
].filter((path) => !written.includes(path)),
|
|
62
|
+
findings: [...handle.findings, ...ungranted, ...adminWiring.findings, ...indexSync.findings],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// every generated endpoint answered 403. Apart from that module because this one only REPORTS:
|
|
4
4
|
// it re-asks the same pure edits of the file as it now stands and names what they could not place.
|
|
5
5
|
|
|
6
|
+
import type { GenerateDisk } from './generate-disk';
|
|
7
|
+
import { appDisk } from './generate-disk';
|
|
6
8
|
import type { RoleGrant } from './generate-grants';
|
|
7
9
|
import {
|
|
8
10
|
adminGrantsFor,
|
|
@@ -11,7 +13,6 @@ import {
|
|
|
11
13
|
insertPermissions,
|
|
12
14
|
writtenTables,
|
|
13
15
|
} from './generate-grants';
|
|
14
|
-
import { containedPath } from './generate-write';
|
|
15
16
|
import type { Finding } from './output';
|
|
16
17
|
import { ROLES_FILE } from './permission-grants';
|
|
17
18
|
|
|
@@ -45,15 +46,16 @@ function stillMissing(source: string, grants: readonly RoleGrant[], admin: reado
|
|
|
45
46
|
export async function ungrantedByGenerator(
|
|
46
47
|
root: string,
|
|
47
48
|
written: readonly string[],
|
|
49
|
+
disk: GenerateDisk = appDisk(root),
|
|
48
50
|
): Promise<readonly Finding[]> {
|
|
49
|
-
const admin = (await writtenTables(root, written)).flatMap(adminGrantsFor);
|
|
51
|
+
const admin = (await writtenTables(root, written, disk)).flatMap(adminGrantsFor);
|
|
50
52
|
const grants = [...grantsForWritten(written), ...admin];
|
|
51
53
|
if (grants.length === 0) return [];
|
|
52
|
-
const
|
|
54
|
+
const source = await disk.read(ROLES_FILE);
|
|
53
55
|
// The file whose declarations nobody granted: where a reader starts when the map is not ours.
|
|
54
56
|
const declaredIn =
|
|
55
57
|
written.find((path) => path.endsWith('/policy.ts')) ?? written[0] ?? ROLES_FILE;
|
|
56
|
-
if (
|
|
58
|
+
if (source === undefined) {
|
|
57
59
|
return [
|
|
58
60
|
{
|
|
59
61
|
code: 'X_PERMISSION_UNGRANTED',
|
|
@@ -63,12 +65,12 @@ export async function ungrantedByGenerator(
|
|
|
63
65
|
},
|
|
64
66
|
];
|
|
65
67
|
}
|
|
66
|
-
const missing = stillMissing(
|
|
68
|
+
const missing = stillMissing(source, grants, admin);
|
|
67
69
|
if (missing.length === 0) return [];
|
|
68
70
|
return [
|
|
69
71
|
{
|
|
70
72
|
code: 'X_PERMISSION_UNGRANTED',
|
|
71
|
-
cause: `${ROLES_FILE} has no role, no "grants: [...]" list or no definePermissions([...]) these could be added to, so nothing granted: ${linesFor(missing)}`,
|
|
73
|
+
cause: `${ROLES_FILE} has no role, no "grants: [...]" list or no definePermissions([...]) these could be safely added to, so nothing granted: ${linesFor(missing)}`,
|
|
72
74
|
fix: `edit ${ROLES_FILE} — add to each role's grants (and declare any the map does not know in definePermissions([...])) — ${linesFor(missing)} — then x verify --only policy`,
|
|
73
75
|
at: ROLES_FILE,
|
|
74
76
|
},
|
package/src/generate-grants.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// three permissions `defineAdmin()` derives from the table's own name. They are declared in the
|
|
8
8
|
// role map and granted to `admin`, or the role that runs the admin is refused its newest screen.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import type { GenerateDisk } from './generate-disk';
|
|
11
|
+
import { appDisk } from './generate-disk';
|
|
11
12
|
import { ROLES_FILE } from './permission-grants';
|
|
12
|
-
import {
|
|
13
|
+
import { absentFrom, appendToList, maskOf, readList, readProperties } from './source-list-edit';
|
|
14
|
+
import { isEntityModule } from './templates/entity-module';
|
|
13
15
|
|
|
14
16
|
/** One permission to add to one role's `grants`. */
|
|
15
17
|
export interface RoleGrant {
|
|
@@ -37,8 +39,6 @@ export function grantsForWritten(written: readonly string[]): readonly RoleGrant
|
|
|
37
39
|
});
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
/** A generated entity file: `<surface>/<feature>/entity.ts`. */
|
|
41
|
-
const ENTITY_PATH = /^apps\/[^/]+\/[^/]+\/[a-z0-9-]+\/entity\.ts$/;
|
|
42
42
|
/** `entity('widgets', {` — the name the admin's permissions are spelled with. */
|
|
43
43
|
const ENTITY_NAME = /\bentity\(\s*'([^']+)'/;
|
|
44
44
|
|
|
@@ -56,11 +56,31 @@ export function adminGrantsFor(table: string): readonly RoleGrant[] {
|
|
|
56
56
|
|
|
57
57
|
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
58
58
|
|
|
59
|
+
const quoted = (permission: string): string => `'${permission}'`;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Where the object of role `name` opens: a property of the `defineRoles({ … })` call, or — in a
|
|
63
|
+
* map handed to it by name — the scaffold's own ` <role>: {` row. Read off the masked text, so a
|
|
64
|
+
* role named in a comment or a string is prose and never the one edited.
|
|
65
|
+
*/
|
|
66
|
+
function roleObject(source: string, masked: string, name: string): number | undefined {
|
|
67
|
+
const call = /\bdefineRoles\(\s*\{/.exec(masked);
|
|
68
|
+
if (call !== null) {
|
|
69
|
+
const role = readProperties(source, masked, call.index + call[0].length - 1).find(
|
|
70
|
+
(property) => property.key === name,
|
|
71
|
+
);
|
|
72
|
+
return role !== undefined && masked[role.value] === '{' ? role.value : undefined;
|
|
73
|
+
}
|
|
74
|
+
const row = new RegExp(`\\n ${escapeRegExp(name)}: \\{`).exec(masked);
|
|
75
|
+
return row === null ? undefined : row.index + row[0].length - 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
59
78
|
/**
|
|
60
|
-
* `source` with each grant added to its role's `grants: [...]
|
|
61
|
-
*
|
|
62
|
-
* `
|
|
63
|
-
* a second,
|
|
79
|
+
* `source` with each grant added to its role's `grants: [...]`. A list on one line is re-wrapped
|
|
80
|
+
* the way Biome prints it; a list over several lines gains a row and keeps every comment it holds.
|
|
81
|
+
* A role or a `grants` array the map does not have — or one that cannot be edited safely — is left
|
|
82
|
+
* alone and returned in `skipped`: the finding names the edit, so a guess here would be a second,
|
|
83
|
+
* worse answer.
|
|
64
84
|
*/
|
|
65
85
|
export function insertGrants(
|
|
66
86
|
source: string,
|
|
@@ -69,62 +89,63 @@ export function insertGrants(
|
|
|
69
89
|
let next = source;
|
|
70
90
|
const skipped: RoleGrant[] = [];
|
|
71
91
|
for (const grant of grants) {
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const indent = next.slice(lineStart, open);
|
|
85
|
-
const rewritten = wrapList(indent, 'grants: [', entries, ']').slice(indent.length);
|
|
86
|
-
next = `${next.slice(0, open)}${rewritten}${next.slice(close + 1)}`;
|
|
92
|
+
const masked = maskOf(next);
|
|
93
|
+
const role = roleObject(next, masked, grant.role);
|
|
94
|
+
const list =
|
|
95
|
+
role === undefined
|
|
96
|
+
? undefined
|
|
97
|
+
: readProperties(next, masked, role).find((property) => property.key === 'grants');
|
|
98
|
+
const edited =
|
|
99
|
+
list === undefined || masked[list.value] !== '['
|
|
100
|
+
? undefined
|
|
101
|
+
: appendToList(next, list.value, [quoted(grant.permission)]);
|
|
102
|
+
if (edited === undefined) skipped.push(grant);
|
|
103
|
+
else next = edited;
|
|
87
104
|
}
|
|
88
105
|
return { source: next, skipped };
|
|
89
106
|
}
|
|
90
107
|
|
|
91
|
-
|
|
108
|
+
/** Every `definePermissions([` in the masked text: the index of each call's opening `[`. */
|
|
109
|
+
const declarationLists = (masked: string): readonly number[] =>
|
|
110
|
+
[...masked.matchAll(/\bdefinePermissions\(\s*\[/g)].map(
|
|
111
|
+
(found) => found.index + found[0].length - 1,
|
|
112
|
+
);
|
|
92
113
|
|
|
93
114
|
/**
|
|
94
|
-
* `source` with each permission added to its first `definePermissions([…])
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* A role map with no such call
|
|
115
|
+
* `source` with each permission added to its first `definePermissions([…])`. Declared BEFORE it is
|
|
116
|
+
* granted: `can()` refuses a name no call registered, and `defineRoles()` does not — a grant
|
|
117
|
+
* nothing declares is a 500 on the first request that asks. One ANY call already declares is left
|
|
118
|
+
* alone. A role map with no such call, or one that cannot be edited safely, is left alone and
|
|
119
|
+
* every permission returned in `skipped`.
|
|
98
120
|
*/
|
|
99
121
|
export function insertPermissions(
|
|
100
122
|
source: string,
|
|
101
123
|
permissions: readonly string[],
|
|
102
124
|
): { readonly source: string; readonly skipped: readonly string[] } {
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const added =
|
|
125
|
+
const masked = maskOf(source);
|
|
126
|
+
const lists = declarationLists(masked).flatMap((open) => readList(source, masked, open) ?? []);
|
|
127
|
+
const first = lists[0];
|
|
128
|
+
if (first === undefined) return { source, skipped: [...permissions] };
|
|
129
|
+
const added = lists.reduce<readonly string[]>(
|
|
130
|
+
(absent, list) => absentFrom(list, absent),
|
|
131
|
+
permissions.map(quoted),
|
|
132
|
+
);
|
|
108
133
|
if (added.length === 0) return { source, skipped: [] };
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const rewritten = wrapList('', `${head}${DECLARATION}`, entries, `])${tail}`);
|
|
114
|
-
return {
|
|
115
|
-
source: `${source.slice(0, lineStart)}${rewritten}${source.slice(source.indexOf('\n', close))}`,
|
|
116
|
-
skipped: [],
|
|
117
|
-
};
|
|
134
|
+
const edited = appendToList(source, first.open, added);
|
|
135
|
+
return edited === undefined
|
|
136
|
+
? { source, skipped: [...permissions] }
|
|
137
|
+
: { source: edited, skipped: [] };
|
|
118
138
|
}
|
|
119
139
|
|
|
120
|
-
/** The entity names the written
|
|
140
|
+
/** The entity names the written entity files declare — read off each file, never guessed. */
|
|
121
141
|
export async function writtenTables(
|
|
122
142
|
root: string,
|
|
123
143
|
written: readonly string[],
|
|
144
|
+
disk: GenerateDisk = appDisk(root),
|
|
124
145
|
): Promise<readonly string[]> {
|
|
125
146
|
const tables: string[] = [];
|
|
126
|
-
for (const path of written.filter(
|
|
127
|
-
const table = ENTITY_NAME.exec(await
|
|
147
|
+
for (const path of written.filter(isEntityModule)) {
|
|
148
|
+
const table = ENTITY_NAME.exec((await disk.read(path)) ?? '')?.[1];
|
|
128
149
|
if (table !== undefined) tables.push(table);
|
|
129
150
|
}
|
|
130
151
|
return tables;
|
|
@@ -134,24 +155,30 @@ export async function writtenTables(
|
|
|
134
155
|
* Performs both edits on the app's role map: a written policy's grants, and — for each written
|
|
135
156
|
* entity — the admin's three permissions for its table, declared and granted. Answers the paths
|
|
136
157
|
* it rewrote.
|
|
158
|
+
*
|
|
159
|
+
* A permission is declared only where its grant can be placed. An app with no `admin` role got
|
|
160
|
+
* three permissions declared that no role held — `X_PERMISSION_UNGRANTED` on the next gate, for
|
|
161
|
+
* names the author never asked for — beside the finding that said nothing was granted.
|
|
137
162
|
*/
|
|
138
163
|
export async function grantGeneratedPermissions(
|
|
139
164
|
root: string,
|
|
140
165
|
written: readonly string[],
|
|
166
|
+
disk: GenerateDisk = appDisk(root),
|
|
141
167
|
): Promise<readonly string[]> {
|
|
142
|
-
const admin = (await writtenTables(root, written)).flatMap(adminGrantsFor);
|
|
168
|
+
const admin = (await writtenTables(root, written, disk)).flatMap(adminGrantsFor);
|
|
143
169
|
const grants = [...grantsForWritten(written), ...admin];
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
-
const
|
|
170
|
+
const before = grants.length === 0 ? undefined : await disk.read(ROLES_FILE);
|
|
171
|
+
if (before === undefined) return [];
|
|
172
|
+
const unplaced = insertGrants(before, grants).skipped;
|
|
173
|
+
const placeable = grants.filter((grant) => !unplaced.includes(grant));
|
|
147
174
|
const declared = insertPermissions(
|
|
148
175
|
before,
|
|
149
|
-
admin.map((grant) => grant.permission),
|
|
176
|
+
admin.filter((grant) => placeable.includes(grant)).map((grant) => grant.permission),
|
|
150
177
|
);
|
|
151
178
|
// Granted only where it could be declared: a grant with no declaration is the 500 above.
|
|
152
|
-
const grantable =
|
|
179
|
+
const grantable = placeable.filter((grant) => !declared.skipped.includes(grant.permission));
|
|
153
180
|
const { source } = insertGrants(declared.source, grantable);
|
|
154
181
|
if (source === before) return [];
|
|
155
|
-
await
|
|
182
|
+
await disk.write(ROLES_FILE, source);
|
|
156
183
|
return [ROLES_FILE];
|
|
157
184
|
}
|
package/src/generate-kinds.ts
CHANGED
|
@@ -244,18 +244,23 @@ const exampleFor = (kind: Generator): string =>
|
|
|
244
244
|
* space is refused for the same reason one step earlier: the value is spliced into a string
|
|
245
245
|
* literal in emitted source, and neither is a permission any app declares.
|
|
246
246
|
*
|
|
247
|
+
* Two segments or MORE: `admin:blog:write` is a `${string}:${string}` like any other, an app's
|
|
248
|
+
* staff console declares its permissions that way, and one colon exactly refused every one of them
|
|
249
|
+
* — the page was then generated under `blog:write` and renamed by hand in three places. An empty
|
|
250
|
+
* segment (`ops::read`, `ops:`) is still no permission.
|
|
251
|
+
*
|
|
247
252
|
* It cannot ask whether the app DECLARES the permission — `x g` writes files against a root, it
|
|
248
253
|
* never loads the app, and an app that will not import is exactly when a generator is reached for.
|
|
249
254
|
* The generated page declaring it is what closes that half (`templates/admin-page.ts`).
|
|
250
255
|
*/
|
|
251
|
-
const PERMISSION_SHAPE = /^[a-z0-9][a-z0-9_.-]
|
|
256
|
+
const PERMISSION_SHAPE = /^[a-z0-9][a-z0-9_.-]*(?::[a-z0-9*][a-z0-9_.*-]*)+$/i;
|
|
252
257
|
|
|
253
258
|
export function readPermission(raw: string | undefined, kind: Generator): string | undefined {
|
|
254
259
|
if (raw === undefined || PERMISSION_SHAPE.test(raw)) return raw;
|
|
255
260
|
throw new BadFlagError({
|
|
256
261
|
flag: 'permission',
|
|
257
262
|
command: 'g',
|
|
258
|
-
reason: `expects a permission of the form <resource>:<verb
|
|
263
|
+
reason: `expects a permission of the form <resource>:<verb> — or namespaced, <area>:<resource>:<verb> — got "${raw}"`,
|
|
259
264
|
fix: `${exampleFor(kind)} --permission ops:read`,
|
|
260
265
|
});
|
|
261
266
|
}
|
package/src/generate-plural.ts
CHANGED
|
@@ -33,6 +33,17 @@ export function refusePluralTable(
|
|
|
33
33
|
feature: string | undefined,
|
|
34
34
|
exists: (path: string) => boolean,
|
|
35
35
|
): void {
|
|
36
|
+
// A feature's second entity (`entity-<name>.ts`) is named by the NAME, never by the slice.
|
|
37
|
+
const sibling = files.find((file) => /\/entity-[a-z0-9-]+\.ts$/.test(file.path));
|
|
38
|
+
const single = sibling === undefined ? undefined : singularOf(kebab(name));
|
|
39
|
+
if (single !== undefined && feature !== undefined) {
|
|
40
|
+
throw new BadFlagError({
|
|
41
|
+
flag: 'name',
|
|
42
|
+
command: `g ${kind}`,
|
|
43
|
+
reason: `"${name}" is already plural, and the entity this run writes pluralises it again for its table — "${names(name).table}"`,
|
|
44
|
+
fix: ['x g', kind, single, '--feature', feature].join(' '),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
36
47
|
for (const slice of writtenEntitySlices(files, exists)) {
|
|
37
48
|
const singular = singularOf(slice);
|
|
38
49
|
if (singular === undefined) continue;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Whether a feature's `entity.ts` makes the next `x g entity --feature` a SIBLING
|
|
2
|
+
// (`entity-<name>.ts`). Decided on what the file READABLY declares, and conservatively: the
|
|
3
|
+
// sibling path skips the conflict on `entity.ts`, so taking the same table for "another one" —
|
|
4
|
+
// because it is spelled as a template literal, or held in a constant — wrote a second module for
|
|
5
|
+
// one table where the run should have refused.
|
|
6
|
+
|
|
7
|
+
import { endOfLiteral, maskLiterals, QUOTES } from '@ultimat3/core';
|
|
8
|
+
import { names } from './templates/naming';
|
|
9
|
+
|
|
10
|
+
/** `entity(` as a call of the imported function — never a member (`schema.entity(`). */
|
|
11
|
+
const ENTITY_CALL = /(?<![.\w$])entity\(\s*/g;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* True only when `source` declares at least one entity, EVERY `entity(…)` call in it names its
|
|
15
|
+
* table as a plain string literal, none of those tables is `name`'s, and no binding in the file
|
|
16
|
+
* is `name`'s own. Anything this cannot read — a constant, an interpolation — answers false: the
|
|
17
|
+
* run then takes the ordinary path, and an existing `entity.ts` is the conflict it always was.
|
|
18
|
+
*/
|
|
19
|
+
export function declaresOnlyOtherEntities(source: string, name: string): boolean {
|
|
20
|
+
const masked = maskLiterals(source);
|
|
21
|
+
const calls = [...masked.matchAll(ENTITY_CALL)];
|
|
22
|
+
if (calls.length === 0) return false;
|
|
23
|
+
const { table, camel } = names(name);
|
|
24
|
+
// A camel spelling is `[A-Za-z0-9]` only (`naming.ts`), so it splices into a pattern as is.
|
|
25
|
+
if (new RegExp(`\\b(?:const|let|var)\\s+${camel}\\b`).test(masked)) return false;
|
|
26
|
+
return calls.every((call) => {
|
|
27
|
+
const at = call.index + call[0].length;
|
|
28
|
+
if (!QUOTES.has(masked[at] ?? '')) return false;
|
|
29
|
+
const end = endOfLiteral(source, at);
|
|
30
|
+
if (end === at + 1) return false;
|
|
31
|
+
const value = source.slice(at + 1, end - 1);
|
|
32
|
+
return !value.includes('${') && !value.includes('\\') && value !== table;
|
|
33
|
+
});
|
|
34
|
+
}
|
package/src/generated-imports.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// a generator that emits one hands the author a red gate for the generator's own output — the edit
|
|
4
4
|
// is mechanical, so the generator makes it. Shipped source only, the checker's own scope.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import type { GenerateDisk } from './generate-disk';
|
|
7
|
+
import { appDisk } from './generate-disk';
|
|
7
8
|
import { isGenerated, isTest } from './source-files';
|
|
8
9
|
import { withDependency } from './workspace-dep-edit';
|
|
9
10
|
import { importedPackages, scanWorkspaces, type WorkspaceNode } from './workspace-graph';
|
|
@@ -14,6 +15,7 @@ const SOURCE = /\.tsx?$/;
|
|
|
14
15
|
async function missingEdges(
|
|
15
16
|
root: string,
|
|
16
17
|
written: readonly string[],
|
|
18
|
+
disk: GenerateDisk,
|
|
17
19
|
): Promise<Map<WorkspaceNode, Map<string, string>>> {
|
|
18
20
|
const missing = new Map<WorkspaceNode, Map<string, string>>();
|
|
19
21
|
const sources = written.filter(
|
|
@@ -27,7 +29,7 @@ async function missingEdges(
|
|
|
27
29
|
for (const path of sources) {
|
|
28
30
|
const owner = owners.find((node) => path.startsWith(`${node.dir}/`));
|
|
29
31
|
if (owner === undefined) continue;
|
|
30
|
-
const text = await
|
|
32
|
+
const text = (await disk.read(path)) ?? '';
|
|
31
33
|
for (const name of importedPackages(text, path)) {
|
|
32
34
|
const target = byName.get(name);
|
|
33
35
|
if (target === undefined || target === owner || owner.dependencies.includes(name)) continue;
|
|
@@ -43,16 +45,17 @@ async function missingEdges(
|
|
|
43
45
|
export async function declareGeneratedImports(
|
|
44
46
|
root: string,
|
|
45
47
|
written: readonly string[],
|
|
48
|
+
disk: GenerateDisk = appDisk(root),
|
|
46
49
|
): Promise<readonly string[]> {
|
|
47
50
|
const edited: string[] = [];
|
|
48
|
-
for (const [owner, edges] of await missingEdges(root, written)) {
|
|
51
|
+
for (const [owner, edges] of await missingEdges(root, written, disk)) {
|
|
49
52
|
const path = `${owner.dir}/package.json`;
|
|
50
|
-
|
|
51
|
-
const before = await
|
|
53
|
+
// Through the seam, not `owner.dependencies`: an earlier edit of this run may already hold it.
|
|
54
|
+
const before = (await disk.read(path)) ?? '';
|
|
52
55
|
let after = before;
|
|
53
56
|
for (const [name, version] of edges) after = withDependency(after, name, version) ?? after;
|
|
54
57
|
if (after === before) continue;
|
|
55
|
-
await
|
|
58
|
+
await disk.write(path, after);
|
|
56
59
|
edited.push(path);
|
|
57
60
|
}
|
|
58
61
|
return edited;
|
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
// the two lines.
|
|
6
6
|
|
|
7
7
|
import { ERROR_DOCS_URL, stripComments } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import type { GenerateDisk } from './generate-disk';
|
|
9
|
+
import { appDisk } from './generate-disk';
|
|
9
10
|
import type { Finding } from './output';
|
|
10
11
|
import { holdsKey, withKeyedLine, withSortedImport } from './source-edit';
|
|
12
|
+
import { closingBracket, maskOf, readList } from './source-list-edit';
|
|
13
|
+
import { entityModuleIn } from './templates/entity-module';
|
|
11
14
|
import type { GeneratedFile } from './templates/naming';
|
|
12
15
|
import { plural } from './templates/naming';
|
|
13
16
|
import type { HandleEntry } from './templates/scaffold-db-client';
|
|
@@ -27,7 +30,6 @@ export const DB_WORKSPACE = 'packages/db';
|
|
|
27
30
|
const WEB_WORKSPACE = 'apps/web';
|
|
28
31
|
const INDEX_FILE = `${DB_WORKSPACE}/src/index.ts`;
|
|
29
32
|
|
|
30
|
-
const ENTITY_PATH = /^apps\/web\/(?<rest>[^/]+\/[^/]+)\/entity\.ts$/;
|
|
31
33
|
const ENTITY_EXPORT = /\bexport const ([A-Za-z_$][\w$]*) = entity\(/;
|
|
32
34
|
const IDENTIFIER = '[A-Za-z_$][\\w$]*';
|
|
33
35
|
|
|
@@ -44,19 +46,34 @@ export const handleUnregisteredFinding = (entry: HandleEntry, reason: string): F
|
|
|
44
46
|
at: HANDLE_FILE,
|
|
45
47
|
});
|
|
46
48
|
|
|
47
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The object literal `database(<name>, …)` is called with: where it opens and where it closes.
|
|
51
|
+
* Found on the MASKED text and closed by bracket depth — the first `}` after the `{` was the end
|
|
52
|
+
* of the set, so one in a comment or in an entry's own value cut the set short.
|
|
53
|
+
*/
|
|
48
54
|
function entitySetOf(
|
|
49
55
|
source: string,
|
|
50
56
|
): { readonly open: number; readonly close: number } | undefined {
|
|
51
|
-
const
|
|
57
|
+
const masked = maskOf(source);
|
|
58
|
+
const call = new RegExp(`\\bdatabase\\(\\s*(${IDENTIFIER})\\s*[,)]`).exec(masked);
|
|
52
59
|
if (call === null) return undefined;
|
|
53
|
-
const declared = new RegExp(`\\bconst ${call[1]}\\s*=\\s*\\{`).exec(
|
|
60
|
+
const declared = new RegExp(`\\bconst ${call[1]}\\s*=\\s*\\{`).exec(masked);
|
|
54
61
|
if (declared === null) return undefined;
|
|
55
62
|
const open = declared.index + declared[0].length;
|
|
56
|
-
const close =
|
|
63
|
+
const close = closingBracket(masked, open - 1);
|
|
57
64
|
return close === -1 ? undefined : { open, close };
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
/** Whether the set opening before `open` reads back as what it held plus `line`'s entry. */
|
|
68
|
+
function setGained(before: string, after: string, open: number, line: string): boolean {
|
|
69
|
+
const was = readList(before, maskOf(before), open - 1);
|
|
70
|
+
const now = readList(after, maskOf(after), open - 1);
|
|
71
|
+
if (was === undefined || now === undefined) return false;
|
|
72
|
+
const expected = [...was.entries.map((entry) => entry.text), line.trim().replace(/,$/, '')];
|
|
73
|
+
const held = now.entries.map((entry) => entry.text);
|
|
74
|
+
return held.length === expected.length && expected.every((text) => held.includes(text));
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
/**
|
|
61
78
|
* `source` with each entry imported and listed. An entry whose key the set already holds is left
|
|
62
79
|
* alone; a file with no `const <set> = { … }` handed to `database()` is returned untouched with
|
|
@@ -77,21 +94,24 @@ export function insertHandleEntries(
|
|
|
77
94
|
const body = next.slice(set.open, set.close);
|
|
78
95
|
if (holdsKey(stripComments(body), entry.key)) continue;
|
|
79
96
|
const listed = withKeyedLine(body, entry.key, handleEntryLine(entry), ' ');
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
const edited = `${next.slice(0, set.open)}${listed}${next.slice(set.close)}`;
|
|
98
|
+
// Read back: a set this line-edit misread (a comment shaped like an entry, a value spanning
|
|
99
|
+
// rows) is refused with the two lines named, never written wrong.
|
|
100
|
+
if (!setGained(next, edited, set.open, handleEntryLine(entry))) {
|
|
101
|
+
missing.push(entry);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
next = withSortedImport(edited, handleImportLine(entry), entry.specifier);
|
|
85
105
|
}
|
|
86
106
|
return { source: next, missing };
|
|
87
107
|
}
|
|
88
108
|
|
|
89
109
|
/** The entry an `entity.ts` implies, named by what the file exports; `undefined` for any other path. */
|
|
90
110
|
function entryOf(path: string, source: string, webModule: string): HandleEntry | undefined {
|
|
91
|
-
const
|
|
111
|
+
const module = entityModuleIn(path, WEB_WORKSPACE);
|
|
92
112
|
const binding = ENTITY_EXPORT.exec(source)?.[1];
|
|
93
|
-
if (
|
|
94
|
-
return { key: plural(binding), binding, specifier: `${webModule}/${
|
|
113
|
+
if (module === undefined || binding === undefined) return undefined;
|
|
114
|
+
return { key: plural(binding), binding, specifier: `${webModule}/${module}` };
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
/**
|
|
@@ -117,14 +137,14 @@ export function withHandleEntries(
|
|
|
117
137
|
|
|
118
138
|
/** The entries the written files imply: one per `entity.ts` this run put on disk. */
|
|
119
139
|
async function entriesFor(
|
|
120
|
-
|
|
140
|
+
disk: GenerateDisk,
|
|
121
141
|
written: readonly string[],
|
|
122
142
|
webModule: string,
|
|
123
143
|
): Promise<readonly HandleEntry[]> {
|
|
124
144
|
const entries: HandleEntry[] = [];
|
|
125
145
|
for (const path of written) {
|
|
126
|
-
if (
|
|
127
|
-
const entry = entryOf(path, await
|
|
146
|
+
if (entityModuleIn(path, WEB_WORKSPACE) === undefined) continue;
|
|
147
|
+
const entry = entryOf(path, (await disk.read(path)) ?? '', webModule);
|
|
128
148
|
if (entry !== undefined) entries.push(entry);
|
|
129
149
|
}
|
|
130
150
|
return entries;
|
|
@@ -136,11 +156,26 @@ export interface HandleRegistration {
|
|
|
136
156
|
readonly findings: readonly Finding[];
|
|
137
157
|
}
|
|
138
158
|
|
|
159
|
+
const exportsHandle = (index: string): boolean => /from\s+'\.\/client'/.test(stripComments(index));
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The db package is the app's OWN: it has an index, no handle, and exports none. A handle written
|
|
163
|
+
* into it is a file nothing imports — `packages/db/src/client.ts` appeared in an app whose db
|
|
164
|
+
* package is a schema and a seed, beside a finding that said the index did not export it. Said
|
|
165
|
+
* instead, with what the generated repo expects, and nothing is created.
|
|
166
|
+
*/
|
|
167
|
+
const ownDbPackageFinding = (entry: HandleEntry, dbModule: string): Finding => ({
|
|
168
|
+
code: 'X_DB_HANDLE_UNREGISTERED',
|
|
169
|
+
cause: `${DB_WORKSPACE} has its own ${INDEX_FILE} and no typed handle (${HANDLE_FILE}), so "import { db } from '${dbModule}'" in the generated repo is not database()'s and db.${entry.key} does not exist — no handle was created in a package that does not export one`,
|
|
170
|
+
// Command first, the edit behind a `#`: the line runs as printed and names what it then proves.
|
|
171
|
+
fix: `x verify --only typecheck # after rewriting the generated repo over this app's own client, or adopting the typed handle: create ${HANDLE_FILE} exporting "db = database({ ${entry.key}: ${entry.binding} }, { driver })" with ${handleImportLine(entry)} and add export { db, driver, selectDriver } from './client'; to ${INDEX_FILE}`,
|
|
172
|
+
docs: ERROR_DOCS_URL,
|
|
173
|
+
at: INDEX_FILE,
|
|
174
|
+
});
|
|
175
|
+
|
|
139
176
|
/** The handle exists but the package does not export it: the repo's import reaches something else. */
|
|
140
|
-
async function indexFindings(
|
|
141
|
-
|
|
142
|
-
const source = (await file.exists()) ? await file.text() : '';
|
|
143
|
-
if (/from\s+'\.\/client'/.test(stripComments(source))) return [];
|
|
177
|
+
async function indexFindings(disk: GenerateDisk, dbModule: string): Promise<readonly Finding[]> {
|
|
178
|
+
if (exportsHandle((await disk.read(INDEX_FILE)) ?? '')) return [];
|
|
144
179
|
return [
|
|
145
180
|
{
|
|
146
181
|
code: 'X_DB_HANDLE_UNREGISTERED',
|
|
@@ -154,7 +189,10 @@ async function indexFindings(root: string, dbModule: string): Promise<readonly F
|
|
|
154
189
|
|
|
155
190
|
/**
|
|
156
191
|
* Performs the registration for every entity among `written`: the handle's set and import, then
|
|
157
|
-
* the manifest lines the new edges need. Creates the handle in an app that has none
|
|
192
|
+
* the manifest lines the new edges need. Creates the handle in an app that has none AND whose db
|
|
193
|
+
* package would export it — an index that names `./client`, or no index at all. A db package with
|
|
194
|
+
* its own index and no handle is the app's own layout: nothing is created there, and the finding
|
|
195
|
+
* says what the generated repo expects.
|
|
158
196
|
*
|
|
159
197
|
* PLANNED, then written — all of it or none. A handle with no `const <set> = { … }` refuses the
|
|
160
198
|
* entry, and a run that then went on to edit `packages/db/package.json` left a manifest line for
|
|
@@ -170,15 +208,19 @@ export async function registerGeneratedEntities(
|
|
|
170
208
|
root: string,
|
|
171
209
|
written: readonly string[],
|
|
172
210
|
dbModule: string | undefined,
|
|
211
|
+
disk: GenerateDisk = appDisk(root),
|
|
173
212
|
): Promise<HandleRegistration> {
|
|
174
|
-
const web = await readWorkspace(root, WEB_WORKSPACE);
|
|
213
|
+
const web = await readWorkspace(root, WEB_WORKSPACE, disk);
|
|
175
214
|
const module = dbModule ?? PLACEHOLDER_DB_MODULE;
|
|
176
215
|
const webModule = web?.name ?? webModuleOf(module);
|
|
177
|
-
const entries = await entriesFor(
|
|
216
|
+
const entries = await entriesFor(disk, written, webModule);
|
|
178
217
|
if (entries.length === 0) return { edited: [], findings: [] };
|
|
179
218
|
const planned: PlannedEdit[] = [];
|
|
180
|
-
const
|
|
181
|
-
const
|
|
219
|
+
const before = await disk.read(HANDLE_FILE);
|
|
220
|
+
const index = before === undefined ? await disk.read(INDEX_FILE) : undefined;
|
|
221
|
+
if (index !== undefined && !exportsHandle(index)) {
|
|
222
|
+
return { edited: [], findings: entries.map((entry) => ownDbPackageFinding(entry, module)) };
|
|
223
|
+
}
|
|
182
224
|
const { source, missing } =
|
|
183
225
|
before === undefined
|
|
184
226
|
? { source: dbClientSource(entries), missing: [] }
|
|
@@ -202,16 +244,16 @@ export async function registerGeneratedEntities(
|
|
|
202
244
|
docs: ERROR_DOCS_URL,
|
|
203
245
|
at: `${DB_WORKSPACE}/package.json`,
|
|
204
246
|
});
|
|
205
|
-
} else findings.push(...(await indexFindings(
|
|
247
|
+
} else findings.push(...(await indexFindings(disk, dbModule)));
|
|
206
248
|
const importsWeb = stripComments(source).includes(`from '${webModule}/`);
|
|
207
249
|
const edges = [
|
|
208
250
|
...(importsWeb ? [[DB_WORKSPACE, WEB_WORKSPACE] as const] : []),
|
|
209
251
|
[WEB_WORKSPACE, DB_WORKSPACE] as const,
|
|
210
252
|
];
|
|
211
253
|
for (const [from, to] of edges) {
|
|
212
|
-
const manifest = await planWorkspaceDependency(root, from, to);
|
|
254
|
+
const manifest = await planWorkspaceDependency(root, from, to, disk);
|
|
213
255
|
if (manifest !== undefined) planned.push(manifest);
|
|
214
256
|
}
|
|
215
|
-
for (const edit of planned) await
|
|
257
|
+
for (const edit of planned) await disk.write(edit.path, edit.contents);
|
|
216
258
|
return { edited: planned.map((edit) => edit.path), findings };
|
|
217
259
|
}
|