@ultimat3/cli 27.9.0 → 27.10.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/package.json +28 -28
- package/src/cmd-generate.ts +10 -1
- package/src/error-codes.ts +2 -0
- package/src/generate-files.ts +4 -0
- package/src/generate-reexports.ts +40 -0
- package/src/generate-slice-policy.ts +29 -0
- package/src/mcp-errors.ts +2 -0
- package/src/templates/action.ts +112 -12
- package/src/templates/slice-foundation.ts +66 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.10.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -39,33 +39,33 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@babel/core": "^8.0.1",
|
|
42
|
-
"@ultimat3/action": "27.
|
|
43
|
-
"@ultimat3/admin": "27.
|
|
44
|
-
"@ultimat3/ai": "27.
|
|
45
|
-
"@ultimat3/auth": "27.
|
|
46
|
-
"@ultimat3/cache": "27.
|
|
47
|
-
"@ultimat3/core": "27.
|
|
48
|
-
"@ultimat3/db": "27.
|
|
49
|
-
"@ultimat3/entity": "27.
|
|
50
|
-
"@ultimat3/flags": "27.
|
|
51
|
-
"@ultimat3/http": "27.
|
|
52
|
-
"@ultimat3/i18n": "27.
|
|
53
|
-
"@ultimat3/jobs": "27.
|
|
54
|
-
"@ultimat3/mail": "27.
|
|
55
|
-
"@ultimat3/manifest": "27.
|
|
56
|
-
"@ultimat3/mcp": "27.
|
|
57
|
-
"@ultimat3/money": "27.
|
|
58
|
-
"@ultimat3/notify": "27.
|
|
59
|
-
"@ultimat3/policy": "27.
|
|
60
|
-
"@ultimat3/pwa": "27.
|
|
61
|
-
"@ultimat3/query": "27.
|
|
62
|
-
"@ultimat3/realtime": "27.
|
|
63
|
-
"@ultimat3/render": "27.
|
|
64
|
-
"@ultimat3/schema": "27.
|
|
65
|
-
"@ultimat3/seo": "27.
|
|
66
|
-
"@ultimat3/storage": "27.
|
|
67
|
-
"@ultimat3/testing": "27.
|
|
68
|
-
"@ultimat3/time": "27.
|
|
42
|
+
"@ultimat3/action": "27.10.0",
|
|
43
|
+
"@ultimat3/admin": "27.10.0",
|
|
44
|
+
"@ultimat3/ai": "27.10.0",
|
|
45
|
+
"@ultimat3/auth": "27.10.0",
|
|
46
|
+
"@ultimat3/cache": "27.10.0",
|
|
47
|
+
"@ultimat3/core": "27.10.0",
|
|
48
|
+
"@ultimat3/db": "27.10.0",
|
|
49
|
+
"@ultimat3/entity": "27.10.0",
|
|
50
|
+
"@ultimat3/flags": "27.10.0",
|
|
51
|
+
"@ultimat3/http": "27.10.0",
|
|
52
|
+
"@ultimat3/i18n": "27.10.0",
|
|
53
|
+
"@ultimat3/jobs": "27.10.0",
|
|
54
|
+
"@ultimat3/mail": "27.10.0",
|
|
55
|
+
"@ultimat3/manifest": "27.10.0",
|
|
56
|
+
"@ultimat3/mcp": "27.10.0",
|
|
57
|
+
"@ultimat3/money": "27.10.0",
|
|
58
|
+
"@ultimat3/notify": "27.10.0",
|
|
59
|
+
"@ultimat3/policy": "27.10.0",
|
|
60
|
+
"@ultimat3/pwa": "27.10.0",
|
|
61
|
+
"@ultimat3/query": "27.10.0",
|
|
62
|
+
"@ultimat3/realtime": "27.10.0",
|
|
63
|
+
"@ultimat3/render": "27.10.0",
|
|
64
|
+
"@ultimat3/schema": "27.10.0",
|
|
65
|
+
"@ultimat3/seo": "27.10.0",
|
|
66
|
+
"@ultimat3/storage": "27.10.0",
|
|
67
|
+
"@ultimat3/testing": "27.10.0",
|
|
68
|
+
"@ultimat3/time": "27.10.0",
|
|
69
69
|
"babel-preset-solid": "^1.9.15"
|
|
70
70
|
}
|
|
71
71
|
}
|
package/src/cmd-generate.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { followUpEdits } from './generate-follow-ups';
|
|
|
19
19
|
import type { Generator } from './generate-kinds';
|
|
20
20
|
import { readFeature, readKind, readName, readPermission, readSurface } from './generate-kinds';
|
|
21
21
|
import { refusePluralTable } from './generate-plural';
|
|
22
|
+
import { followReexports } from './generate-reexports';
|
|
22
23
|
import { refuseShadowedTypes, refuseShadowedValues } from './generate-shadow';
|
|
23
24
|
import { declaresOnlyOtherEntities } from './generate-sibling';
|
|
24
25
|
import { containedPath, planWrites, writeFiles } from './generate-write';
|
|
@@ -90,6 +91,12 @@ export const generateCommand: CliCommand = {
|
|
|
90
91
|
// A named slice that is not there is refused, never invented (X_FEATURE_UNKNOWN).
|
|
91
92
|
assertFeatureExists(root, kind, featureFlag, slice);
|
|
92
93
|
const sliceErrors = await readSliceErrors(root, kind, slice);
|
|
94
|
+
// And its `policy.ts`, for the same two kinds and the same reason: the action imports from it.
|
|
95
|
+
const slicePolicy = await followReexports(
|
|
96
|
+
root,
|
|
97
|
+
slice,
|
|
98
|
+
await readSliceErrors(root, kind, slice, 'policy.ts'),
|
|
99
|
+
);
|
|
93
100
|
// Same reason again: whether `job`/`task` may assume the tenant-scoped shape is a fact about
|
|
94
101
|
// THIS feature's own `entity.ts`/`repo.ts`, not a default the template gets to assume.
|
|
95
102
|
const sliceEntity = await readSliceFile(root, kind, slice, 'entity.ts');
|
|
@@ -105,6 +112,7 @@ export const generateCommand: CliCommand = {
|
|
|
105
112
|
name: planned,
|
|
106
113
|
...(featureFlag === undefined ? {} : { feature: featureFlag }),
|
|
107
114
|
...(sliceErrors === undefined ? {} : { sliceErrors }),
|
|
115
|
+
...(slicePolicy === undefined ? {} : { slicePolicy }),
|
|
108
116
|
...(sliceEntity === undefined ? {} : { sliceEntity }),
|
|
109
117
|
...(sliceRepo === undefined ? {} : { sliceRepo }),
|
|
110
118
|
...(at === undefined ? {} : { at }),
|
|
@@ -321,9 +329,10 @@ async function readSliceErrors(
|
|
|
321
329
|
root: string,
|
|
322
330
|
kind: Generator,
|
|
323
331
|
slice: string,
|
|
332
|
+
name: 'errors.ts' | 'policy.ts' = 'errors.ts',
|
|
324
333
|
): Promise<string | undefined> {
|
|
325
334
|
if (kind !== 'action' && kind !== 'mutator') return undefined;
|
|
326
|
-
const file = containedPath(root, `${slice}
|
|
335
|
+
const file = containedPath(root, `${slice}/${name}`);
|
|
327
336
|
return existsSync(file) ? await Bun.file(file).text() : undefined;
|
|
328
337
|
}
|
|
329
338
|
|
package/src/error-codes.ts
CHANGED
|
@@ -233,6 +233,7 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
233
233
|
'X_ISLAND_MODULE_DUPLICATED',
|
|
234
234
|
'X_BACKFILL_SLICE_ENTITY',
|
|
235
235
|
'X_LAYOUT_SIBLING_MODULE',
|
|
236
|
+
'X_SLICE_POLICY_INCOMPLETE',
|
|
236
237
|
] as const;
|
|
237
238
|
|
|
238
239
|
/**
|
|
@@ -422,6 +423,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
422
423
|
X_ISLAND_MODULE_DUPLICATED: 'An island chunk bundles one module more than once',
|
|
423
424
|
X_BACKFILL_SLICE_ENTITY: 'the slice holds no entity a generated backfill can sweep',
|
|
424
425
|
X_LAYOUT_SIBLING_MODULE: 'a primitive module beside its directory',
|
|
426
|
+
X_SLICE_POLICY_INCOMPLETE: 'a generator needs an export the slice policy.ts does not declare',
|
|
425
427
|
};
|
|
426
428
|
|
|
427
429
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
package/src/generate-files.ts
CHANGED
|
@@ -70,6 +70,8 @@ export interface GenerateOptions {
|
|
|
70
70
|
readonly sliceEntity?: string;
|
|
71
71
|
/** The same kinds: the slice's `repo.ts` as it stands on disk, absent alongside `sliceEntity`. */
|
|
72
72
|
readonly sliceRepo?: string;
|
|
73
|
+
/** `action` and `mutator`: the slice's `policy.ts` as it stands on disk, absent when it has none. */
|
|
74
|
+
readonly slicePolicy?: string;
|
|
73
75
|
/**
|
|
74
76
|
* The app's own db package, `@<app>/db` — what every generated `repo.ts` imports the typed handle
|
|
75
77
|
* from. Supplied by `run` (`resolveDbModule`) for `catalogModule`'s reason: the name lives in a
|
|
@@ -152,6 +154,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
152
154
|
actionFiles(options.name, {
|
|
153
155
|
...target,
|
|
154
156
|
...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
|
|
157
|
+
...(options.slicePolicy === undefined ? {} : { slicePolicy: options.slicePolicy }),
|
|
155
158
|
...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
|
|
156
159
|
...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
|
|
157
160
|
}),
|
|
@@ -162,6 +165,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
162
165
|
...target,
|
|
163
166
|
mutator: true,
|
|
164
167
|
...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
|
|
168
|
+
...(options.slicePolicy === undefined ? {} : { slicePolicy: options.slicePolicy }),
|
|
165
169
|
...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
|
|
166
170
|
...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
|
|
167
171
|
}),
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Single responsibility: reading a slice module's relative `export * from './x'` targets off the
|
|
2
|
+
// app's disk, one level deep, so a barrel is judged by what it really exports.
|
|
3
|
+
|
|
4
|
+
// why: Bun has no synchronous "is this a FILE": a directory passes `existsSync`, and reading one
|
|
5
|
+
// as a file is an uncoded EISDIR.
|
|
6
|
+
import { statSync } from 'node:fs';
|
|
7
|
+
import { containedPath } from './generate-write';
|
|
8
|
+
import { inlineReexports } from './templates/slice-foundation';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A slice module with its relative `export * from './x'` lines read in, one level deep — so a
|
|
12
|
+
* `policy.ts` that is a barrel is judged by what it really exports (`inlineReexports`).
|
|
13
|
+
*/
|
|
14
|
+
export async function followReexports(
|
|
15
|
+
root: string,
|
|
16
|
+
slice: string,
|
|
17
|
+
source: string | undefined,
|
|
18
|
+
): Promise<string | undefined> {
|
|
19
|
+
if (source === undefined) return undefined;
|
|
20
|
+
const found = new Map<string, string>();
|
|
21
|
+
for (const match of source.matchAll(/\bexport\s*\*\s*from\s*['"](\.[^'"]+)['"]/g)) {
|
|
22
|
+
const specifier = match[1] ?? '';
|
|
23
|
+
for (const candidate of [`${specifier}.ts`, `${specifier}/index.ts`, specifier]) {
|
|
24
|
+
const file = containedPath(root, `${slice}/${candidate}`);
|
|
25
|
+
// A FILE, or it is not followed: `./rules` naming a directory with no `index.ts` is left as
|
|
26
|
+
// the `export *` it was, which is "cannot tell".
|
|
27
|
+
if (found.has(specifier) || !isFile(file)) continue;
|
|
28
|
+
found.set(specifier, await Bun.file(file).text());
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return inlineReexports(source, (specifier) => found.get(specifier));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isFile(path: string): boolean {
|
|
35
|
+
try {
|
|
36
|
+
return statSync(path).isFile();
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Single responsibility: the refusal `x g action` / `x g mutator` raise when the slice they were
|
|
2
|
+
// pointed at has a hand-written `policy.ts` without the exports the generated file imports.
|
|
3
|
+
// Declared outside `templates/` (source the CLI emits) and beside nothing else, as
|
|
4
|
+
// `generate-feature.ts`'s `FeatureUnknownError` is: `errors.ts` sits at its line ceiling.
|
|
5
|
+
|
|
6
|
+
import { UltimateError } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/** Thrown while the files are PLANNED, so nothing is on disk when it is. */
|
|
9
|
+
export class SlicePolicyIncompleteError extends UltimateError {
|
|
10
|
+
constructor(input: {
|
|
11
|
+
readonly policy: string;
|
|
12
|
+
readonly feature: string;
|
|
13
|
+
readonly missing: readonly string[];
|
|
14
|
+
readonly kind: 'action' | 'mutator';
|
|
15
|
+
}) {
|
|
16
|
+
super({
|
|
17
|
+
code: 'X_SLICE_POLICY_INCOMPLETE',
|
|
18
|
+
cause: `${input.policy} does not export ${input.missing.join(' or ')}, which the ${input.kind} x g ${input.kind} writes imports from it — the file is hand-written, a generator never edits it, and nothing was written`,
|
|
19
|
+
// The fix IS the edit: `x g policy --dry-run` prints a write plan, never the source to copy.
|
|
20
|
+
fix: `${input.missing.map((name) => exportLine(name, input.feature)).join(' ')} # add to ${input.policy} (tag from @ultimat3/cache, can from @ultimat3/policy; the permission must be one its definePermissions() declares), then run x g ${input.kind} again`,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The export line a scaffolded `policy.ts` carries for `name`. */
|
|
26
|
+
const exportLine = (name: string, feature: string): string =>
|
|
27
|
+
name.endsWith('Tag')
|
|
28
|
+
? `export const ${name} = tag('${feature}');`
|
|
29
|
+
: `export const ${name} = can('${feature}:write');`;
|
package/src/mcp-errors.ts
CHANGED
|
@@ -304,6 +304,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
304
304
|
'x errors explain X_BACKFILL_SLICE_ENTITY --json # the refusal itself names the x g job line for this slice',
|
|
305
305
|
X_LAYOUT_SIBLING_MODULE:
|
|
306
306
|
'x verify --only boundaries --json # then move each declaration in X.ts to X/<name>.ts, one primitive per file',
|
|
307
|
+
X_SLICE_POLICY_INCOMPLETE:
|
|
308
|
+
'x policy list --json # names the slice policy module; add the export lines the thrown fix spells out to it, then run the generator again',
|
|
307
309
|
};
|
|
308
310
|
|
|
309
311
|
const isCliCode = (code: string): code is CliErrorCode =>
|
package/src/templates/action.ts
CHANGED
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
// pins both. One declaration projects to an HTTP route, an OpenAPI operation, a typed client, a
|
|
3
3
|
// job handle, an MCP tool and these tests; the generator writes the declaration and the tests.
|
|
4
4
|
|
|
5
|
+
import { SlicePolicyIncompleteError } from '../generate-slice-policy';
|
|
5
6
|
import type { FeatureTarget } from './entity';
|
|
6
7
|
import { sortedImports } from './imports';
|
|
7
8
|
import type { GeneratedFile, NameSet } from './naming';
|
|
8
9
|
import { names } from './naming';
|
|
9
10
|
import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
exportsUnknown,
|
|
13
|
+
grantOf,
|
|
14
|
+
sliceExports,
|
|
15
|
+
sliceFoundation,
|
|
16
|
+
sliceTakesScaffoldRow,
|
|
17
|
+
} from './slice-foundation';
|
|
11
18
|
import { wrapImport } from './wrap';
|
|
12
19
|
|
|
13
20
|
/**
|
|
@@ -177,6 +184,12 @@ interface HandlerShape {
|
|
|
177
184
|
/** The slice still takes the row `x g entity` scaffolds, so the test may store one. */
|
|
178
185
|
readonly storesRow: boolean;
|
|
179
186
|
readonly dbModule: string;
|
|
187
|
+
/**
|
|
188
|
+
* The permission the writer is granted — `<feature>:write` for the policy this generator wrote,
|
|
189
|
+
* the one literal `can('<perm>')` of a handwritten one, or `undefined` when that policy's grant
|
|
190
|
+
* cannot be read, and then no assertion depends on it.
|
|
191
|
+
*/
|
|
192
|
+
readonly grant: string | undefined;
|
|
180
193
|
}
|
|
181
194
|
|
|
182
195
|
/**
|
|
@@ -204,6 +217,12 @@ unitTest('the local twin invents no row the store never held', () => {
|
|
|
204
217
|
|
|
205
218
|
/** The authoritative half's cases, refusal first, in the shape the slice earns. */
|
|
206
219
|
const handlerTests = (feature: NameSet, shape: HandlerShape): string => {
|
|
220
|
+
if (shape.grant === undefined) {
|
|
221
|
+
return `
|
|
222
|
+
// No handler assertion: ../policy.ts is hand-written and its grant is not one literal can('…'),
|
|
223
|
+
// so which actor reaches the handler is the author's to state — add that test here.
|
|
224
|
+
`;
|
|
225
|
+
}
|
|
207
226
|
const answer = shape.isMutator ? "{ id, title: 'a title' }" : '{ id }';
|
|
208
227
|
if (!shape.lookup) {
|
|
209
228
|
return `
|
|
@@ -253,7 +272,8 @@ unitTest('the handler answers the row it found', async () => {
|
|
|
253
272
|
* it would be a guess about someone else's table.
|
|
254
273
|
*/
|
|
255
274
|
const actionUnitTest = (name: NameSet, feature: NameSet, shape: HandlerShape): string => {
|
|
256
|
-
const
|
|
275
|
+
const known = shape.grant !== undefined;
|
|
276
|
+
const stores = known && shape.lookup && shape.storesRow;
|
|
257
277
|
const testing = [
|
|
258
278
|
...(stores ? ['afterEach'] : []),
|
|
259
279
|
'expect',
|
|
@@ -264,7 +284,7 @@ const actionUnitTest = (name: NameSet, feature: NameSet, shape: HandlerShape): s
|
|
|
264
284
|
// process, so all three belong to the \`unit\` step. The contract projections are next door.
|
|
265
285
|
${sortedImports([
|
|
266
286
|
...(stores ? ["import { ctxOf, runWithContext } from '@ultimat3/core';"] : []),
|
|
267
|
-
"import { testActor } from '@ultimat3/policy';",
|
|
287
|
+
...(known ? ["import { testActor } from '@ultimat3/policy';"] : []),
|
|
268
288
|
`import { ${testing} } from '@ultimat3/testing';`,
|
|
269
289
|
...(stores ? [`import { driver } from '${shape.dbModule}';`] : []),
|
|
270
290
|
])}${stores ? "\nimport * as repo from '../repo';" : ''}
|
|
@@ -278,11 +298,15 @@ ${shape.isMutator ? `\n// The client store's table: the entity's own, so the twi
|
|
|
278
298
|
// At boot \`registerActions(await import('./actions'))\` stamps the same name onto the same
|
|
279
299
|
// object, so each projection of it works there with nothing to remember.
|
|
280
300
|
const target = ${name.camel}.named('${name.camel}');
|
|
281
|
-
|
|
301
|
+
${
|
|
302
|
+
known
|
|
303
|
+
? `
|
|
282
304
|
// Holds the grant in the org the input names: past the policy, so what answers is the handler.
|
|
283
|
-
const grant = ['${
|
|
305
|
+
const grant = ['${shape.grant}'];
|
|
284
306
|
const writer = testActor('writer', { orgId, permissions: grant }).actor;
|
|
285
|
-
|
|
307
|
+
`
|
|
308
|
+
: ''
|
|
309
|
+
}${
|
|
286
310
|
stores
|
|
287
311
|
? `
|
|
288
312
|
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
@@ -305,9 +329,11 @@ const actionContractTest = (
|
|
|
305
329
|
name: NameSet,
|
|
306
330
|
feature: NameSet,
|
|
307
331
|
isMutator: boolean,
|
|
332
|
+
/** The slice's policy is the one this generator wrote, so its tenancy rule is known. */
|
|
333
|
+
scaffoldPolicy = true,
|
|
308
334
|
): string => `// ${name.camel}: the contract every action owes, and the foreign-org actor it denies before the
|
|
309
335
|
// handler runs. One declaration, every surface.
|
|
310
|
-
${preamble(name, feature, isMutator, 'contractTest, expect',
|
|
336
|
+
${preamble(name, feature, isMutator, 'contractTest, expect', scaffoldPolicy)}
|
|
311
337
|
contractTest('${name.camel} passes the action contract', async () => {
|
|
312
338
|
// Three assertions the framework makes for any action, without knowing what this one does:
|
|
313
339
|
// garbage input is rejected, an anonymous actor is denied, and the operation reaches the
|
|
@@ -315,13 +341,19 @@ contractTest('${name.camel} passes the action contract', async () => {
|
|
|
315
341
|
for (const contract of target.contract()) await contract.run();
|
|
316
342
|
});
|
|
317
343
|
|
|
318
|
-
|
|
344
|
+
${
|
|
345
|
+
scaffoldPolicy
|
|
346
|
+
? `contractTest('${name.camel} denies a foreign org', async () => {
|
|
319
347
|
// \`.as()\` is the one execution path with the actor swapped, so this denial is the same one
|
|
320
348
|
// HTTP, MCP and the job surface would produce — and no repo call happened to produce it.
|
|
321
349
|
const denied = await target.as(outsider, input).catch((error: unknown) => error);
|
|
322
350
|
expect(denied).toBeUltimateError('X_FORBIDDEN');
|
|
323
351
|
});
|
|
324
|
-
|
|
352
|
+
`
|
|
353
|
+
: `// No foreign-org assertion: ../policy.ts is hand-written, and whether it checks the org is the
|
|
354
|
+
// author's rule to state — add that test here.
|
|
355
|
+
`
|
|
356
|
+
}
|
|
325
357
|
// No MCP tool until it is described: a placeholder description misleads the agent reading it.
|
|
326
358
|
contractTest('${name.camel} projects one operation and no tool', () => {
|
|
327
359
|
expect(target.openapi().operationId).toBe('${name.camel}');
|
|
@@ -346,6 +378,12 @@ export interface ActionOptions extends FeatureTarget {
|
|
|
346
378
|
readonly sliceEntity?: string;
|
|
347
379
|
/** The slice's `repo.ts` as it stands on disk, or absent alongside `sliceEntity`. */
|
|
348
380
|
readonly sliceRepo?: string;
|
|
381
|
+
/**
|
|
382
|
+
* The slice's `policy.ts` as it stands on disk, absent when the slice has none yet (and then the
|
|
383
|
+
* scaffolded one is written). Read for `sliceErrors`'s reason: the generated file IMPORTS from
|
|
384
|
+
* it, and what it exports is a fact about this app's disk.
|
|
385
|
+
*/
|
|
386
|
+
readonly slicePolicy?: string;
|
|
349
387
|
}
|
|
350
388
|
|
|
351
389
|
export function actionFiles(rawName: string, target: ActionOptions): readonly GeneratedFile[] {
|
|
@@ -354,16 +392,26 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
|
|
|
354
392
|
const dir = `${target.surfaceDir}/${target.feature}/actions`;
|
|
355
393
|
const isMutator = target.mutator === true;
|
|
356
394
|
const entity = target.sliceEntity !== undefined;
|
|
395
|
+
// Before a file is planned: a generated action that does not compile is refused, never written.
|
|
396
|
+
assertPolicyExports(target, feature, isMutator);
|
|
357
397
|
const lookup =
|
|
358
398
|
entity &&
|
|
359
399
|
(target.sliceErrors === undefined ||
|
|
360
|
-
sliceExports(target.sliceErrors, `${feature.pascal}NotFoundError`))
|
|
400
|
+
sliceExports(target.sliceErrors, `${feature.pascal}NotFoundError`)) &&
|
|
401
|
+
// The lookup calls `repo.byId`: a `repo.ts` an author wrote without one gets the neutral body.
|
|
402
|
+
(target.sliceRepo === undefined || sliceExports(target.sliceRepo, 'byId'));
|
|
361
403
|
return [
|
|
362
404
|
// The three slice modules this action's source imports — `../errors`, `../policy`, `../repo`
|
|
363
405
|
// (which comes with `../entity`, its row type). Composed rather than assumed: `x g action`
|
|
364
406
|
// into a slice no `x g resource` had created emitted all three imports and wrote none of them.
|
|
365
407
|
// With no entity, only the policy: a lookup-free body imports neither ../errors nor ../repo.
|
|
366
|
-
|
|
408
|
+
// And ONLY those: an action is not a resource scaffold. A module already on disk is its
|
|
409
|
+
// author's — it gets no test from here and no admin labels, both of which assert the shape
|
|
410
|
+
// `x g resource` writes, not the one that is there (`withoutOwned`).
|
|
411
|
+
...withoutOwned(
|
|
412
|
+
sliceFoundation(target, entity ? ['entity', 'policy', 'errors'] : ['policy']),
|
|
413
|
+
target,
|
|
414
|
+
),
|
|
367
415
|
{
|
|
368
416
|
path: `${dir}/${name.kebab}.ts`,
|
|
369
417
|
contents: isMutator
|
|
@@ -381,11 +429,63 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
|
|
|
381
429
|
lookup,
|
|
382
430
|
storesRow: sliceTakesScaffoldRow(target, target.sliceEntity, target.sliceRepo),
|
|
383
431
|
dbModule: target.dbModule ?? PLACEHOLDER_DB_MODULE,
|
|
432
|
+
// The policy this run writes grants `<feature>:write`; a handwritten one, what it says.
|
|
433
|
+
grant:
|
|
434
|
+
target.slicePolicy === undefined
|
|
435
|
+
? `${feature.kebab}:write`
|
|
436
|
+
: grantOf(target.slicePolicy, `can${feature.pascal}Write`),
|
|
384
437
|
}),
|
|
385
438
|
},
|
|
386
439
|
{
|
|
387
440
|
path: `${dir}/${name.kebab}.contract.test.ts`,
|
|
388
|
-
contents: actionContractTest(name, feature, isMutator),
|
|
441
|
+
contents: actionContractTest(name, feature, isMutator, target.slicePolicy === undefined),
|
|
389
442
|
},
|
|
390
443
|
];
|
|
391
444
|
}
|
|
445
|
+
|
|
446
|
+
/** The slice modules a run may find already written, and the files each would otherwise be handed. */
|
|
447
|
+
const OWNED = [
|
|
448
|
+
['sliceEntity', ['/entity.ts', '/entity.test.ts', '.json']],
|
|
449
|
+
['sliceRepo', ['/repo.ts', '/repo.test.ts']],
|
|
450
|
+
['slicePolicy', ['/policy.ts', '/policy.test.ts']],
|
|
451
|
+
] as const;
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The planted slice files, minus everything that belongs to a module ALREADY on disk. `if-absent`
|
|
455
|
+
* protects a file; it does not know that `repo.test.ts` belongs to a `repo.ts` somebody wrote, so
|
|
456
|
+
* a hand-written slice was handed three tests asserting the scaffold's shape and six admin labels
|
|
457
|
+
* (the catalog `.json`) for an entity it had declared itself. A module this run does write keeps
|
|
458
|
+
* every companion.
|
|
459
|
+
*/
|
|
460
|
+
function withoutOwned(
|
|
461
|
+
files: readonly GeneratedFile[],
|
|
462
|
+
target: ActionOptions,
|
|
463
|
+
): readonly GeneratedFile[] {
|
|
464
|
+
const owned = OWNED.flatMap(([module, suffixes]) =>
|
|
465
|
+
target[module] === undefined ? [] : suffixes,
|
|
466
|
+
);
|
|
467
|
+
return files.filter((file) => !owned.some((suffix) => file.path.endsWith(suffix)));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Refuse a slice whose own `policy.ts` lacks what the generated file imports from it. An absent
|
|
472
|
+
* `policy.ts` is not this case — the scaffolded one is written beside the action, with both names.
|
|
473
|
+
*
|
|
474
|
+
* Refused rather than adapted, unlike a missing `<Feature>NotFoundError`: there is no neutral
|
|
475
|
+
* policy to fall back to. An inline `can('<feature>:write')` names a permission the slice's
|
|
476
|
+
* `definePermissions()` never declared, which is a type error of its own.
|
|
477
|
+
*/
|
|
478
|
+
function assertPolicyExports(target: ActionOptions, feature: NameSet, isMutator: boolean): void {
|
|
479
|
+
const source = target.slicePolicy;
|
|
480
|
+
// A barrel re-exporting a module nobody could read: cannot tell, so never refused on a guess.
|
|
481
|
+
if (source === undefined || exportsUnknown(source)) return;
|
|
482
|
+
const needed = [`can${feature.pascal}Write`, ...(isMutator ? [] : [`${feature.camel}Tag`])];
|
|
483
|
+
const missing = needed.filter((name) => !sliceExports(source, name));
|
|
484
|
+
if (missing.length === 0) return;
|
|
485
|
+
throw new SlicePolicyIncompleteError({
|
|
486
|
+
policy: `${target.surfaceDir}/${target.feature}/policy.ts`,
|
|
487
|
+
feature: target.feature,
|
|
488
|
+
missing,
|
|
489
|
+
kind: isMutator ? 'mutator' : 'action',
|
|
490
|
+
});
|
|
491
|
+
}
|
|
@@ -131,7 +131,59 @@ export function sliceExports(source: string, name: string): boolean {
|
|
|
131
131
|
const declared = new RegExp(
|
|
132
132
|
`\\bexport\\s+(?:abstract\\s+)?(?:class|const|let|var|(?:async\\s+)?function|enum)\\s+${name}\\b`,
|
|
133
133
|
);
|
|
134
|
-
return
|
|
134
|
+
return (
|
|
135
|
+
declared.test(code) || listedExports(code).includes(name) || destructured(code).includes(name)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The names an `export const { a, b: c } = …` declares — `a` and `c`, never `b`. */
|
|
140
|
+
const destructured = (code: string): readonly string[] =>
|
|
141
|
+
[...code.matchAll(/\bexport\s+(?:const|let|var)\s*\{([^}]*)\}\s*=/g)].flatMap((match) =>
|
|
142
|
+
(match[1] ?? '')
|
|
143
|
+
.split(',')
|
|
144
|
+
.map((entry) => (entry.split(':').at(-1) ?? '').split('=')[0]?.trim() ?? '')
|
|
145
|
+
.filter((entry) => entry !== ''),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const STAR = /\bexport\s*\*\s*from\s*['"]([^'"]+)['"]\s*;?/g;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `source` with each `export * from '<specifier>'` replaced by the module `read` answers for it —
|
|
152
|
+
* ONE level, which is what a slice's barrel is. A specifier `read` cannot answer (a package, a
|
|
153
|
+
* missing file) is left as it stands, and that leftover is what `exportsUnknown` reports.
|
|
154
|
+
*/
|
|
155
|
+
export function inlineReexports(
|
|
156
|
+
source: string,
|
|
157
|
+
read: (specifier: string) => string | undefined,
|
|
158
|
+
): string {
|
|
159
|
+
return stripComments(source).replace(STAR, (statement: string, specifier: string) => {
|
|
160
|
+
const inner = read(specifier);
|
|
161
|
+
// Its own `export *` lines are dropped rather than followed: one level, and a module that
|
|
162
|
+
// re-exports further is "cannot tell" only if THIS file still says so.
|
|
163
|
+
return inner === undefined ? statement : stripComments(inner).replace(STAR, '');
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Whether `source` re-exports a module nobody read: what it exports cannot be known from here. */
|
|
168
|
+
export const exportsUnknown = (source: string): boolean => {
|
|
169
|
+
const code = stripComments(source);
|
|
170
|
+
return /\bexport\s*\*\s*from\b/.test(code) || nestedPattern(code);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether an exported destructuring pattern is one the flat reader above cannot spell: a nested
|
|
175
|
+
* object or array pattern, or a rest element. Its names are then UNKNOWN — never "missing".
|
|
176
|
+
*/
|
|
177
|
+
function nestedPattern(code: string): boolean {
|
|
178
|
+
for (const match of code.matchAll(/\bexport\s+(?:const|let|var)\s*\{/g)) {
|
|
179
|
+
let depth = 1;
|
|
180
|
+
for (let at = (match.index ?? 0) + match[0].length; at < code.length && depth > 0; at += 1) {
|
|
181
|
+
const char = code[at];
|
|
182
|
+
if (char === '{' || char === '[' || code.startsWith('...', at)) return true;
|
|
183
|
+
if (char === '}') depth -= 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
135
187
|
}
|
|
136
188
|
|
|
137
189
|
/** ` title: text({ max: 200 }),` — one column of an entity, as its name and its builder. */
|
|
@@ -162,3 +214,16 @@ export function sliceTakesScaffoldRow(
|
|
|
162
214
|
if (wanted === '' || columnsOf(sliceEntity) !== wanted) return false;
|
|
163
215
|
return sliceRepo === undefined || sliceExports(sliceRepo, 'insert');
|
|
164
216
|
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The ONE permission `name` grants, when `source` declares it as a single literal call:
|
|
220
|
+
* `export const canBlogWrite = can('blog:publish');`. Anything else — a predicate argument,
|
|
221
|
+
* `and`/`or`, a function, a re-export — is `undefined`: the grant cannot be read, and a generated
|
|
222
|
+
* test must not assume one.
|
|
223
|
+
*/
|
|
224
|
+
export function grantOf(source: string, name: string): string | undefined {
|
|
225
|
+
const declared = new RegExp(
|
|
226
|
+
`\\bexport\\s+const\\s+${name}\\s*=\\s*can\\(\\s*(['"\`])([A-Za-z0-9_.*:-]+)\\1\\s*\\)\\s*;`,
|
|
227
|
+
);
|
|
228
|
+
return declared.exec(stripComments(source))?.[2];
|
|
229
|
+
}
|