@ultimat3/cli 11.3.0 → 13.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/CLAUDE.md +50 -1
- package/README.md +4 -4
- package/package.json +29 -28
- package/src/app-permissions.ts +0 -0
- package/src/cmd-dev.ts +6 -0
- package/src/cmd-doctor.ts +74 -16
- package/src/cmd-errors.ts +6 -0
- package/src/cmd-generate.ts +2 -27
- package/src/cmd-i18n.ts +16 -1
- package/src/dev-queue.ts +51 -37
- package/src/dev-replica.ts +99 -0
- package/src/dev-roles-fixture.ts +7 -0
- package/src/dev-roles.ts +44 -30
- package/src/dev-sync.ts +45 -1
- package/src/error-catalog.ts +1 -0
- package/src/error-codes.ts +21 -0
- package/src/framework-schema.ts +152 -0
- package/src/i18n-index.ts +40 -0
- package/src/i18n-registration.ts +43 -1
- package/src/index.ts +10 -0
- package/src/mcp-errors.ts +15 -0
- package/src/messages.ts +6 -1
- package/src/parse.ts +39 -6
- package/src/port-probe.ts +22 -0
- package/src/schema-errors.ts +28 -0
- package/src/serve.ts +7 -1
- package/src/templates/scaffold-app.ts +2 -0
- package/src/templates/scaffold-docs.ts +10 -4
- package/src/templates/scaffold-http.ts +84 -0
- package/src/templates/scaffold-repo.ts +20 -0
- package/src/templates/scaffold-roles.ts +31 -3
- package/src/verify-checks.ts +36 -0
- package/src/verify-step.ts +8 -0
|
@@ -82,6 +82,21 @@ const rootPackage = (app: NameSet, version: string): string => `{
|
|
|
82
82
|
}
|
|
83
83
|
`;
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* `"incremental": true` is ONE line and it is the difference between a 4.9s typecheck and a 92s
|
|
87
|
+
* one. `x verify`'s first step is `tsc -b`, and `-b` decides "up to date?" by comparing emitted
|
|
88
|
+
* OUTPUTS against inputs — with `noEmit` and no `composite`/`references`, the output it looks for
|
|
89
|
+
* is an `app.config.js` that will never exist (`Project 'tsconfig.json' is out of date because
|
|
90
|
+
* output file 'app.config.js' does not exist`), so every run rebuilt the whole program from
|
|
91
|
+
* scratch, forever. Measured on a 166-file scaffold with no source change between runs: 92s wall
|
|
92
|
+
* / 43s user CPU without it, 4.9s / 8.8s warm with it, and the whole gate at 12s rather than
|
|
93
|
+
* 24-71s. This was the only tree in the framework without incremental typechecking — the repo
|
|
94
|
+
* root has 32 `references` and `examples/dummy/tsconfig.json` sets `composite`.
|
|
95
|
+
*
|
|
96
|
+
* The note lives HERE and not in the emitted file, for the reason `biome.json` below gives: an
|
|
97
|
+
* app author has no use for eight lines of framework archaeology in their own tsconfig, and
|
|
98
|
+
* `*.tsbuildinfo` is already in the scaffold's `.gitignore`.
|
|
99
|
+
*/
|
|
85
100
|
const rootTsconfig = (app: NameSet): string => `{
|
|
86
101
|
"compilerOptions": {
|
|
87
102
|
"target": "ES2023",
|
|
@@ -101,6 +116,7 @@ const rootTsconfig = (app: NameSet): string => `{
|
|
|
101
116
|
"isolatedModules": true,
|
|
102
117
|
"skipLibCheck": true,
|
|
103
118
|
"noEmit": true,
|
|
119
|
+
"incremental": true,
|
|
104
120
|
"resolveJsonModule": true,
|
|
105
121
|
"jsx": "preserve",
|
|
106
122
|
"jsxImportSource": "solid-js"
|
|
@@ -258,6 +274,10 @@ const SCAFFOLD_FLOOR: readonly VerifyStepName[] = [
|
|
|
258
274
|
'eval',
|
|
259
275
|
'drift',
|
|
260
276
|
'budgets',
|
|
277
|
+
// Always applicable to a scaffolded app — it has an `app.config.ts`, roles and two guarded
|
|
278
|
+
// routes — so a run that reports it skipped is a gate that lost the step, not an app with
|
|
279
|
+
// nothing to check. It is the step that would have caught the 500 `x new` used to ship.
|
|
280
|
+
'policy',
|
|
261
281
|
'manifest',
|
|
262
282
|
];
|
|
263
283
|
|
|
@@ -19,7 +19,15 @@ const rolesSource =
|
|
|
19
19
|
// \`x g policy <feature>\` declares \`<feature>:read\` and \`<feature>:write\`. Granting them is this
|
|
20
20
|
// file's job — a permission no role holds is one no actor can ever exercise.
|
|
21
21
|
|
|
22
|
-
import { defineRoles } from '@ultimat3/policy';
|
|
22
|
+
import { definePermissions, defineRoles } from '@ultimat3/policy';
|
|
23
|
+
|
|
24
|
+
// DECLARED before it is granted, and that order is the whole point. \`can()\` calls
|
|
25
|
+
// \`assertPermission\`, which refuses a name no \`definePermissions()\` call registered
|
|
26
|
+
// (X_PERMISSION_UNKNOWN) — and \`defineRoles()\` does NOT: it took \`grants: ['dashboard:read']\`
|
|
27
|
+
// in silence while nothing declared it, so every scaffolded app answered HTTP 500 on /dashboard
|
|
28
|
+
// and /admin from its first \`x dev\`, under a green gate. A permission a role grants and a
|
|
29
|
+
// permission a route requires both belong here.
|
|
30
|
+
export const appPermissions = definePermissions(['admin:read', 'dashboard:read']);
|
|
23
31
|
|
|
24
32
|
export const roles = defineRoles({
|
|
25
33
|
member: {
|
|
@@ -37,9 +45,9 @@ export const roles = defineRoles({
|
|
|
37
45
|
const rolesTest =
|
|
38
46
|
(): string => `// The app's role map, expanded: what each role grants once inheritance is flattened, and which
|
|
39
47
|
// roles hold a given permission. An undeclared role must grant nothing at all.
|
|
40
|
-
import { expandRoles, rolesGranting } from '@ultimat3/policy';
|
|
48
|
+
import { expandRoles, isKnownPermission, rolesGranting } from '@ultimat3/policy';
|
|
41
49
|
import { expect, unitTest } from '@ultimat3/testing';
|
|
42
|
-
import { roles } from './roles';
|
|
50
|
+
import { appPermissions, roles } from './roles';
|
|
43
51
|
|
|
44
52
|
// The map is passed explicitly rather than read off the module-global one: a test that depended on
|
|
45
53
|
// which module imported first would pass alone and fail inside a suite.
|
|
@@ -57,6 +65,26 @@ unitTest('every permission the app enforces is held by some role', () => {
|
|
|
57
65
|
expect(rolesGranting('dashboard:read', roles)).toEqual(['admin', 'member']);
|
|
58
66
|
expect(rolesGranting('admin:read', roles)).toEqual(['admin']);
|
|
59
67
|
});
|
|
68
|
+
|
|
69
|
+
// The assertion whose absence shipped a 500. Expansion above proves the MAP is right and says
|
|
70
|
+
// nothing about the registry \`can()\` actually consults: a grant naming a permission no
|
|
71
|
+
// \`definePermissions()\` declared expands perfectly and then throws X_PERMISSION_UNKNOWN on the
|
|
72
|
+
// first request to the route that requires it.
|
|
73
|
+
unitTest('every granted permission is in the registry can() asks', () => {
|
|
74
|
+
for (const permission of new Set(Object.values(roles).flatMap((role) => role.grants))) {
|
|
75
|
+
expect({ permission, known: isKnownPermission(permission) }).toEqual({
|
|
76
|
+
permission,
|
|
77
|
+
known: true,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
unitTest('the routes this app ships require permissions this app declares', () => {
|
|
83
|
+
// The two \`defineRoute({ policy: { permission } })\` values \`x new\` writes. \`RouteGuard\`
|
|
84
|
+
// keeps a bare string, so nothing but this holds them to the declared set.
|
|
85
|
+
expect(appPermissions.has('dashboard:read')).toBe(true);
|
|
86
|
+
expect(appPermissions.has('admin:read')).toBe(true);
|
|
87
|
+
});
|
|
60
88
|
`;
|
|
61
89
|
|
|
62
90
|
/** `apps/web/shared/roles.ts` and its test. Written by `x new`, with or without the example slice. */
|
package/src/verify-checks.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { checkAppBoundaries } from './app-boundaries';
|
|
|
19
19
|
import { envExampleFindings } from './app-env';
|
|
20
20
|
import { appManifest, readAppManifest } from './app-manifest';
|
|
21
21
|
import { OPENAPI_FILE, openApiJson } from './app-openapi';
|
|
22
|
+
import { policyFindings } from './app-permissions';
|
|
22
23
|
import { APP_CONFIG_FILE } from './app-root';
|
|
23
24
|
import { checkBudgets, readBuildStats } from './budgets';
|
|
24
25
|
import { checkDestructiveMigrations } from './db-destructive';
|
|
@@ -265,6 +266,15 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
265
266
|
applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
|
|
266
267
|
run: async (ctx) => fromFindings(await catalogFindings(ctx.root)),
|
|
267
268
|
},
|
|
269
|
+
{
|
|
270
|
+
name: 'policy',
|
|
271
|
+
summary: 'every permission this app grants or requires is one it declares',
|
|
272
|
+
// A repo with no `app.config.ts` is the framework monorepo, which declares no roles and
|
|
273
|
+
// registers no routes — SKIPPED there, never passed, for the reason `i18n` gives: a step that
|
|
274
|
+
// answers `ok` about nothing is the vacuous green these checks exist to refuse.
|
|
275
|
+
applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
|
|
276
|
+
run: async (ctx) => fromFindings(await policyFindings(ctx.root)),
|
|
277
|
+
},
|
|
268
278
|
{
|
|
269
279
|
name: 'manifest',
|
|
270
280
|
summary: 'the files an agent reads: generated facts, hand-written conventions, the env example',
|
|
@@ -283,6 +293,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
283
293
|
async run(ctx) {
|
|
284
294
|
const agents = await checkAgentsMd(ctx.root);
|
|
285
295
|
const findings = [
|
|
296
|
+
...manifestMissingFindings(ctx.root),
|
|
286
297
|
...(await driftFindings(ctx.root)),
|
|
287
298
|
...(await envExampleFindings(ctx.root)),
|
|
288
299
|
...floorProblemFindings(await readVerifyFloor(ctx.root)),
|
|
@@ -312,6 +323,31 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
312
323
|
},
|
|
313
324
|
];
|
|
314
325
|
|
|
326
|
+
/**
|
|
327
|
+
* The half that had never been asked: is the file there at all? `driftFindings` returns nothing
|
|
328
|
+
* when it is absent — correctly, it has nothing to compare — and `AGENTS.md` line 3 tells an agent
|
|
329
|
+
* that facts live in `x.manifest.json`, `x dev` prints its path, and `x manifest` is the only
|
|
330
|
+
* thing that writes it. Nothing ran it: after `x new`, `bin/setup` and all thirteen generators,
|
|
331
|
+
* `find . -name '*.manifest.json'` returned nothing while this step reported green (#F7).
|
|
332
|
+
*
|
|
333
|
+
* An app root, never this repo: the framework monorepo emits `framework.manifest.json` and has no
|
|
334
|
+
* `x.manifest.json` to be missing, so the `app.config.ts` is what decides — the same discriminator
|
|
335
|
+
* `drift`, `budgets`, `seo`, `i18n` and `policy` each use.
|
|
336
|
+
*/
|
|
337
|
+
function manifestMissingFindings(root: string): readonly Finding[] {
|
|
338
|
+
if (!existsSync(join(root, APP_CONFIG_FILE))) return [];
|
|
339
|
+
if (existsSync(join(root, MANIFEST_FILENAME))) return [];
|
|
340
|
+
return [
|
|
341
|
+
{
|
|
342
|
+
code: 'X_MANIFEST_MISSING',
|
|
343
|
+
cause: `${MANIFEST_FILENAME} does not exist, so every fact an agent reads about this app — route table, action schemas, policies, error codes — is unavailable`,
|
|
344
|
+
fix: 'x manifest',
|
|
345
|
+
docs: ERROR_DOCS_URL,
|
|
346
|
+
at: MANIFEST_FILENAME,
|
|
347
|
+
},
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
|
|
315
351
|
/** `assertNoDrift` throws `X_MANIFEST_DRIFT`; a step reports, so the error becomes a finding. */
|
|
316
352
|
async function driftFindings(root: string): Promise<readonly Finding[]> {
|
|
317
353
|
const path = join(root, MANIFEST_FILENAME);
|
package/src/verify-step.ts
CHANGED
|
@@ -41,6 +41,14 @@ export const VERIFY_STEP_NAMES = [
|
|
|
41
41
|
// registries that load filled. Until it existed, an app could ship every user-facing string as
|
|
42
42
|
// `⟦key⟧` with `x verify` green, because nothing in the gate ever asked (issue #249).
|
|
43
43
|
'i18n',
|
|
44
|
+
// Twentieth, by the same test `seo` and `i18n` each passed: a rider must ask the SAME question
|
|
45
|
+
// off the same data, and "was this import legal?" is not "does the permission this app grants
|
|
46
|
+
// and requires exist?". Reported under `budgets` it would hand the reader a byte budget for an
|
|
47
|
+
// authz defect (axiom 4). Until it existed, `x new` shipped an app that answered HTTP 500 with
|
|
48
|
+
// X_PERMISSION_UNKNOWN on two of its three routes under a green gate: `defineRoles()` accepts an
|
|
49
|
+
// undeclared grant in silence and `RouteGuard.permission` is a bare string. It costs no second
|
|
50
|
+
// app load — `budgets` already imported every module, and this reads the registries that filled.
|
|
51
|
+
'policy',
|
|
44
52
|
'manifest',
|
|
45
53
|
'roadmap',
|
|
46
54
|
] as const;
|