@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.
@@ -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. */
@@ -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);
@@ -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;