@ultimat3/cli 20.1.2 → 20.1.4

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/src/mcp-ui.ts ADDED
@@ -0,0 +1,136 @@
1
+ // The dev MCP server's two eyes: `ui.shot` (a route) and `ui.island` (a component's states), as
2
+ // the `DevCapabilities` half `packages/mcp` declares and cannot satisfy — a browser is the CLI's
3
+ // to launch. Both are `x shot` under another name: the same server lookup (a running `x dev` is
4
+ // reused through its lock, otherwise a scratch one boots), the same driver, the same verdict.
5
+ // Nothing here is a new capability; it is the existing one made reachable from inside the loop
6
+ // an agent already works in, so "does it look right" stops needing a hand-written script.
7
+
8
+ // why: Bun exposes no path-join primitive, and the picture's directory is a path an agent opens.
9
+ import { join } from 'node:path';
10
+ import { UltimateError } from '@ultimat3/core';
11
+ import type { UiIslandInput, UiIslandResult, UiShotInput, UiShotResult } from '@ultimat3/mcp';
12
+ import { describeRoutes } from '@ultimat3/render';
13
+ import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
14
+ import { appBrowser } from './browser-launcher';
15
+ import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
16
+ import { islandShot } from './cmd-shot-island';
17
+ import type { Env } from './dev-services';
18
+ import { islandVerdictJson } from './island-verdict';
19
+ import { shotBrowserChoice } from './shot-browser';
20
+ import { devServerFor } from './shot-server';
21
+ import { verdictJson } from './shot-verdict';
22
+
23
+ /** Kernel-picked, as `x shot` picks it: a scratch server never fights another project for :3000. */
24
+ const SCRATCH_PORT = 0;
25
+
26
+ /**
27
+ * The route a picture is of has to be a route this app declares WITH a JS budget. The gate
28
+ * refuses an unmeasured route (`X_BUDGET_UNMEASURED`), and a picture of a route nobody has
29
+ * finished declaring is a picture of a draft — an agent judging it would judge the wrong thing.
30
+ * Matched on the declared pattern, so `/links/abc123` finds `/links/:slug`.
31
+ */
32
+ export interface DeclaredRoute {
33
+ readonly path: string;
34
+ readonly file: string;
35
+ readonly budgetJs: string | null;
36
+ }
37
+
38
+ export function assertBudgetedRoute(route: string, declared: readonly DeclaredRoute[]): void {
39
+ const path = route.split('?')[0] ?? route;
40
+ const hit = declared.find((entry) => matches(entry.path, path));
41
+ if (hit === undefined) {
42
+ throw new UltimateError({
43
+ code: 'X_UI_SHOT_ROUTE_UNKNOWN',
44
+ cause: `no route in this app answers ${route}`,
45
+ fix: 'x routes --json # then ui.shot with one of its `path` values',
46
+ });
47
+ }
48
+ if (hit.budgetJs === null) {
49
+ throw new UltimateError({
50
+ code: 'X_UI_SHOT_ROUTE_UNBUDGETED',
51
+ cause: `${hit.file} declares no budget.js, so x verify would refuse it as X_BUDGET_UNMEASURED — a picture of it would be a picture of a draft`,
52
+ fix: `declare budget: { js: '<n>kb' } in ${hit.file}, then: x build --target static --json && x verify --only budgets --json`,
53
+ });
54
+ }
55
+ }
56
+
57
+ /** `/links/:slug` matches `/links/abc123`; one segment per `:param`, no globbing. */
58
+ export function matches(pattern: string, path: string): boolean {
59
+ const want = pattern.split('/');
60
+ const have = path.split('/');
61
+ if (want.length !== have.length) return false;
62
+ return want.every((segment, index) => segment.startsWith(':') || segment === have[index]);
63
+ }
64
+
65
+ export interface UiHostInput {
66
+ readonly root: string;
67
+ readonly env: Env;
68
+ }
69
+
70
+ export function uiCapabilities(input: UiHostInput): {
71
+ shotRoute(shot: UiShotInput): Promise<UiShotResult>;
72
+ shotIsland(island: UiIslandInput): Promise<UiIslandResult>;
73
+ } {
74
+ const { root, env } = input;
75
+ const boot = () => devServerFor(root, env, SCRATCH_PORT);
76
+ // The same choice `x shot` makes from the environment: `PUPPETEER_EXECUTABLE_PATH`, a
77
+ // provider's CDP URL, or the launcher's own discovery.
78
+ const browser = () => shotBrowserChoice({ cdpFlag: undefined, browserFlag: undefined, env });
79
+
80
+ return {
81
+ async shotRoute(shot) {
82
+ assertBudgetedRoute(shot.route, describeRoutes());
83
+ const { cdpUrl, executablePath } = browser();
84
+ const driver = await appBrowser({
85
+ root,
86
+ viewport: shot.viewport,
87
+ ...(executablePath === undefined ? {} : { executablePath }),
88
+ ...(cdpUrl === undefined ? {} : { cdpUrl }),
89
+ });
90
+ // One directory per (route, viewport, scheme), so two pictures of one route at two widths
91
+ // never overwrite each other and an agent can hold both.
92
+ const outDir = join(
93
+ root,
94
+ SHOT_DIR,
95
+ shotSlug(shot.route),
96
+ `${shot.viewport.width}x${shot.viewport.height}-${shot.colorScheme}`,
97
+ );
98
+ const artifacts = await runShot({
99
+ route: shot.route,
100
+ outDir,
101
+ driver,
102
+ boot,
103
+ settleMs: DEFAULT_SETTLE_MS,
104
+ timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
105
+ fullPage: shot.fullPage,
106
+ colorScheme: shot.colorScheme,
107
+ });
108
+ return {
109
+ ok: artifacts.verdict.ok,
110
+ image: artifacts.image,
111
+ verdictFile: artifacts.verdictFile,
112
+ verdict: verdictJson(artifacts.verdict),
113
+ };
114
+ },
115
+
116
+ async shotIsland(island) {
117
+ const { cdpUrl, executablePath } = browser();
118
+ const artifacts = await islandShot({
119
+ root,
120
+ island: island.island,
121
+ ...(island.state === undefined ? {} : { state: island.state }),
122
+ settleMs: DEFAULT_SETTLE_MS,
123
+ timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
124
+ ...(executablePath === undefined ? {} : { executablePath }),
125
+ ...(cdpUrl === undefined ? {} : { cdpUrl }),
126
+ boot,
127
+ });
128
+ return {
129
+ ok: artifacts.verdict.ok,
130
+ dir: artifacts.dir,
131
+ verdictFile: artifacts.verdictFile,
132
+ verdict: islandVerdictJson(artifacts.verdict),
133
+ };
134
+ },
135
+ };
136
+ }
package/src/output.ts CHANGED
@@ -64,7 +64,7 @@ export interface CommandResult {
64
64
  * Which fd this result is written to. `stdout` for every command, absent included — and
65
65
  * `stderr` for the one case where fd 1 is not the command's to write on: `x mcp serve
66
66
  * --transport stdio`, whose stdout carries JSON-RPC frames, and where the `✓ mcp stdio serving
67
- * 13 tools` line printed after the loop exits is a malformed frame to whatever is reading.
67
+ * 15 tools` line printed after the loop exits is a malformed frame to whatever is reading.
68
68
  *
69
69
  * Behaviour, not a fact, exactly like `hold` above — so NEITHER renderer carries it. It says
70
70
  * where a rendered line goes, and a payload that also claimed it would be a second answer to a
@@ -21,6 +21,31 @@ export const HANDWRITTEN_ERRORS = `import { UltimateError } from '@ultimat3/core
21
21
  export class LedgerClosedError extends UltimateError {}
22
22
  `;
23
23
 
24
+ /**
25
+ * A feature slice whose entity is NOT tenant-scoped — the shape `x g entity`'s own comment
26
+ * describes for a single-tenant app, and never a shape the generator writes itself. The
27
+ * generator's INPUT only, the same way `HANDWRITTEN_ERRORS` above is: `x g job`/`x g task` read
28
+ * this from a real `entity.ts`, and a fixture that never exercised it compiled only the shape
29
+ * that never failed.
30
+ */
31
+ export const HANDWRITTEN_ENTITY_NO_TENANT = `import { entity, text, uuid } from '@ultimat3/entity';
32
+
33
+ export const shortLink = entity('short_links', {
34
+ columns: { id: uuid().primaryKey(), url: text({ max: 2000 }) },
35
+ });
36
+
37
+ export type ShortLink = typeof shortLink.$row;
38
+ `;
39
+
40
+ /** The paired `repo.ts`: no `byId`, no `listByOrg` — nothing this slice's job may call. */
41
+ export const HANDWRITTEN_REPO_NO_TENANT = `import { db, sql } from '@ultimat3/db';
42
+ import type { ShortLink } from './entity';
43
+
44
+ export async function list(limit = 50): Promise<readonly ShortLink[]> {
45
+ return db().query<ShortLink>(sql\`select * from short_links order by url limit \${limit}\`);
46
+ }
47
+ `;
48
+
24
49
  /**
25
50
  * One realistic invocation of every generator, on top of `x new --example`. Names differ from
26
51
  * their feature on purpose: `x g query invoice --feature invoice` would collide with the entity
@@ -47,6 +72,23 @@ export const FIXTURE_GENERATORS: readonly GenerateOptions[] = [
47
72
  { kind: 'job', name: 'sweep-invoices', feature: 'invoice' },
48
73
  { kind: 'backfill', name: 'reindex-invoices', feature: 'invoice' },
49
74
  { kind: 'task', name: 'nightly-sweep', feature: 'invoice' },
75
+ // The other shape both templates have: a feature whose entity names no tenant column, so the
76
+ // job/task must not assume one — compiled here beside the tenant-scoped pair above, exactly as
77
+ // `ping-invoice`/`touch-invoice` compile the action's other shape beside `send-invoice`.
78
+ {
79
+ kind: 'job',
80
+ name: 'purge-orphans',
81
+ feature: 'short-link',
82
+ sliceEntity: HANDWRITTEN_ENTITY_NO_TENANT,
83
+ sliceRepo: HANDWRITTEN_REPO_NO_TENANT,
84
+ },
85
+ {
86
+ kind: 'task',
87
+ name: 'nightly-purge',
88
+ feature: 'short-link',
89
+ sliceEntity: HANDWRITTEN_ENTITY_NO_TENANT,
90
+ sliceRepo: HANDWRITTEN_REPO_NO_TENANT,
91
+ },
50
92
  { kind: 'route', name: 'pricing', surface: 'site' },
51
93
  { kind: 'route', name: 'billing', surface: 'app' },
52
94
  // `--at`, pointed at the `site/` route above: an island's whole reason to exist is a 0kb page
@@ -74,10 +74,17 @@ export function unzonedDates(files: readonly SourceFile[]): readonly Finding[] {
74
74
  const open = match.index + match[0].length - 1;
75
75
  if (argumentsOf(text, open).includes('timeZone')) continue;
76
76
  const line = lineOf(text, match.index);
77
+ // The bare \`.toLocaleString(\` is ALSO \`Number.prototype.toLocaleString\`, and a regex cannot
78
+ // tell a count from a date. The match stands — a date formatted this way is the defect this
79
+ // guard exists for — but the fix names the number exit too, because \`{ timeZone }\` is not
80
+ // one: a number ignores it, and an author following the fix verbatim would ship a lie.
81
+ const bare = match[0].trim() === '.toLocaleString(';
77
82
  findings.push({
78
83
  code: CODE,
79
84
  cause: \`\${file.path}:\${line} calls \${match[0].trim()}) with no timeZone — it formats in whatever zone the process happens to run in, so one row reads as two different days across two containers\`,
80
- fix: \`pass an explicit IANA zone in \${file.path} — toLocaleDateString(locale, { timeZone: 'UTC' }) — then: x verify\`,
85
+ fix: bare
86
+ ? \`in \${file.path}: a Date → pass an explicit IANA zone, at.toLocaleString(locale, { timeZone: 'UTC' }); a NUMBER → new Intl.NumberFormat(locale).format(n) instead — then: x verify\`
87
+ : \`pass an explicit IANA zone in \${file.path} — toLocaleDateString(locale, { timeZone: 'UTC' }) — then: x verify\`,
81
88
  at: file.path,
82
89
  });
83
90
  }
@@ -130,6 +137,16 @@ unitTest('Intl.DateTimeFormat and toLocaleTimeString are the same rule', () => {
130
137
  expect(unzonedDates(file("at.toLocaleTimeString('en-US');"))).toHaveLength(1);
131
138
  });
132
139
 
140
+ unitTest('the bare toLocaleString names the number exit, since a count matches it too', () => {
141
+ const findings = unzonedDates(file("const shown = count.toLocaleString('en-US');"));
142
+ expect(findings).toHaveLength(1);
143
+ expect(findings[0]?.fix).toContain('Intl.NumberFormat');
144
+ expect(findings[0]?.fix).toContain('timeZone');
145
+ // The dated forms are unambiguous and keep the zone-only fix.
146
+ const dated = unzonedDates(file("at.toLocaleDateString('en-US');"));
147
+ expect(dated[0]?.fix).not.toContain('Intl.NumberFormat');
148
+ });
149
+
133
150
  unitTest('a commented-out call is a note, not a call', () => {
134
151
  expect(unzonedDates(file("// at.toLocaleDateString('en-US');"))).toEqual([]);
135
152
  });
@@ -12,7 +12,8 @@ export { entityFiles } from './entity';
12
12
  export { guardCode, guardFiles } from './guard';
13
13
  export type { IslandOptions } from './island';
14
14
  export { islandFiles } from './island';
15
- export { jobFiles, taskFiles } from './job';
15
+ export type { JobOptions } from './job';
16
+ export { isTenantScopedSlice, jobFiles, taskFiles } from './job';
16
17
  export { CATALOG_ROOT, catalogPath, DEFAULT_LOCALES, resolveLocales } from './locales';
17
18
  // All three members of the `GeneratedFile` union, not two: the barrel exported the union and the
18
19
  // foundation variant only, so a consumer could hold a `GeneratedFile` and had no name to narrow it
@@ -3,10 +3,11 @@
3
3
  // both; the generated test pins them through a real driver, because a key that is not stable is a
4
4
  // job that runs twice and a tenant that is not declared is a job that reads the wrong org's rows.
5
5
 
6
+ import { stripComments } from '../ts-scan';
6
7
  import type { FeatureTarget } from './entity';
7
8
  import type { GeneratedFile, NameSet } from './naming';
8
9
  import { names } from './naming';
9
- import { sliceFoundation } from './slice-foundation';
10
+ import { sliceExports, sliceFoundation } from './slice-foundation';
10
11
 
11
12
  const jobSource = (
12
13
  name: NameSet,
@@ -39,6 +40,42 @@ export const ${name.camel} = job({
39
40
  });
40
41
  `;
41
42
 
43
+ /**
44
+ * The other shape: this feature's own `entity.ts` names no tenant column (or names `'none'`), or
45
+ * its `repo.ts` does not export the `byId`/`listByOrg` pair the tenant-scoped body above calls —
46
+ * checked by `isTenantScopedSlice` against what is actually on the app's disk, never assumed. The
47
+ * body imports neither `../entity` nor `../repo`, so it compiles whether this feature has an
48
+ * entity yet or has one with no tenant, and `tenant: 'none'` is stated rather than defaulted so a
49
+ * reviewer sees the decision instead of an absence.
50
+ */
51
+ const neutralJobSource = (
52
+ name: NameSet,
53
+ ): string => `// ${name.camel}: multi-step durable work with no tenant behind it. Each step is retried
54
+ // independently and its result is stored under its name — step names are stable identifiers, not
55
+ // labels. \`t\` comes from @ultimat3/jobs, not @ultimat3/schema: a job file imports one package.
56
+
57
+ import { job, t } from '@ultimat3/jobs';
58
+
59
+ export const ${name.camel} = job({
60
+ input: t.object({ id: t.uuid }),
61
+ // This feature has no tenant column to derive an org from — either it has no entity yet, or its
62
+ // entity names none. \`tenant: 'none'\` STRIPS the org from the run rather than leaving one
63
+ // behind, so a tenant-scoped read added later fails closed with X_TENANCY_ACTOR_ORG_REQUIRED
64
+ // instead of reading whichever org the enqueuer happened to hold. Once this feature's entity
65
+ // declares \`tenant: 'orgId'\` and its repo exports \`byId\`/\`listByOrg\`, the next \`x g job\` in
66
+ // this slice scaffolds the tenant-scoped shape above instead.
67
+ tenant: 'none',
68
+ idempotencyKey: ({ id }) => \`${name.kebab}:\${id}\`,
69
+ retry: { attempts: 5, backoff: 'exponential' },
70
+ async run({ step }) {
71
+ await step.run('process', async () => {
72
+ // TODO: this job's own work.
73
+ });
74
+ return { processed: true };
75
+ },
76
+ });
77
+ `;
78
+
42
79
  const taskSource = (
43
80
  name: NameSet,
44
81
  jobName: NameSet,
@@ -65,6 +102,25 @@ export const ${name.camel} = task({
65
102
  });
66
103
  `;
67
104
 
105
+ /** The task's other shape: enqueues the neutral job above, so its payload carries no `orgId`. */
106
+ const neutralTaskSource = (
107
+ name: NameSet,
108
+ jobName: NameSet,
109
+ ): string => `// ${name.camel}: a scheduled trigger. Tasks only enqueue jobs — the work itself is durable and
110
+ // retryable, and the schedule carries an explicit IANA time zone.
111
+
112
+ import { task } from '@ultimat3/jobs';
113
+ import { ${jobName.camel} } from '../jobs/${jobName.kebab}';
114
+
115
+ export const ${name.camel} = task({
116
+ cron: '0 3 * * *',
117
+ tz: 'UTC',
118
+ // No org in the payload: the job this enqueues declares \`tenant: 'none'\`, because this
119
+ // feature's entity names no tenant column (or has none yet).
120
+ enqueue: () => [[${jobName.camel}, { id: '00000000-0000-4000-8000-000000000001' }]],
121
+ });
122
+ `;
123
+
68
124
  const jobTest = (
69
125
  name: NameSet,
70
126
  ): string => `// ${name.camel} against a real driver: enqueue, drain, assert. Retries and the dead-letter path
@@ -121,6 +177,63 @@ jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
121
177
  });
122
178
  `;
123
179
 
180
+ /** The job test's other shape: no `orgId` anywhere, and a `tenantFor` that reads `undefined`. */
181
+ const neutralJobTest = (
182
+ name: NameSet,
183
+ ): string => `// ${name.camel} against a real driver: enqueue, drain, assert. Retries and the dead-letter path
184
+ // are the framework's, so what this pins is that THIS job's steps run and are idempotent.
185
+ import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
186
+ import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
187
+ import { ${name.camel} } from './${name.kebab}';
188
+
189
+ const id = '00000000-0000-4000-8000-000000000001';
190
+ const input = { id };
191
+ // The key this job owes, spelled once. Named rather than inlined so the assertion below carries
192
+ // the job's own name and still fits the formatter width the app's \`lint\` step enforces.
193
+ const expectedKey = \`${name.kebab}:\${id}\`;
194
+
195
+ // The driver is process-global, so it is installed and released around this file rather than
196
+ // left behind for whichever test happens to run next.
197
+ beforeAll(() => {
198
+ setJobDriver(createMemoryDriver());
199
+ });
200
+ afterAll(resetJobDriver);
201
+
202
+ jobTest('${name.camel} declares a key and a retry policy', () => {
203
+ expect(${name.camel}.kind).toBe('job');
204
+ expect(${name.camel}.idempotencyKeyFor(input)).toBe(expectedKey);
205
+ expect(${name.camel}.retry.attempts).toBeGreaterThan(1);
206
+ });
207
+
208
+ jobTest('${name.camel} derives the same key for the same input', () => {
209
+ const key = ${name.camel}.idempotencyKeyFor(input);
210
+ expect(${name.camel}.idempotencyKeyFor(input)).toBe(key);
211
+ });
212
+
213
+ jobTest('${name.camel} declares no tenant', () => {
214
+ // This feature's entity names no tenant column (or has none yet) — \`tenant: 'none'\` is the
215
+ // declaration for that, and it strips one rather than inheriting the worker's, which is what
216
+ // stands between a read added here later and X_TENANCY_ACTOR_ORG_REQUIRED, or worse, another
217
+ // org's rows if this ever gains one.
218
+ expect(${name.camel}.tenantFor(input)).toBeUndefined();
219
+ });
220
+
221
+ jobTest('${name.camel} projects itself into the manifest', () => {
222
+ const described = ${name.camel}.describe();
223
+ expect(described.queue).toBe('default');
224
+ expect(described.retry.attempts).toBe(5);
225
+ });
226
+
227
+ jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
228
+ // The whole point of the key: an at-least-once caller may enqueue twice and the work still
229
+ // happens once. \`.enqueue()\` is the one queue path — a job is never run inline.
230
+ const first = await ${name.camel}.enqueue(input);
231
+ expect(first.deduped).toBe(false);
232
+ const again = await ${name.camel}.enqueue(input);
233
+ expect(again.deduped).toBe(true);
234
+ });
235
+ `;
236
+
124
237
  const taskTest = (
125
238
  name: NameSet,
126
239
  jobName: NameSet,
@@ -163,28 +276,75 @@ jobTest('${name.camel} fires its declared entries', async () => {
163
276
  });
164
277
  `;
165
278
 
166
- export function jobFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] {
279
+ export interface JobOptions extends FeatureTarget {
280
+ /**
281
+ * This feature's own `entity.ts` as it stands on disk, or absent when the feature has none yet.
282
+ * Supplied by `run` for the same reason `ActionOptions.sliceErrors` is: whether the slice is
283
+ * tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
284
+ * `repo.byId`/`repo.listByOrg` calls into a feature whose repo never declared them —
285
+ * `x g task purgeOrphans --feature links` on a `links` slice with no `orgId` produced files that
286
+ * did not compile.
287
+ */
288
+ readonly sliceEntity?: string;
289
+ /** This feature's own `repo.ts` as it stands on disk, or absent alongside `sliceEntity`. */
290
+ readonly sliceRepo?: string;
291
+ }
292
+
293
+ /**
294
+ * Whether `x g job`/`x g task` may assume the tenant-scoped shape: an `entity.ts` this feature
295
+ * does not have yet is about to be scaffolded fresh by `sliceFoundation` below, tenant-scoped by
296
+ * default — so absent counts as scoped. One that exists is trusted over that default: it declares
297
+ * a real, non-`'none'` `tenant`, AND its `repo.ts` actually exports the `byId`/`listByOrg` pair the
298
+ * tenant-scoped body calls. Both have to hold — an entity that still names `tenant: 'orgId'` after
299
+ * an author trimmed `listByOrg` out of `repo.ts` (or never generated one) is not a slice this job
300
+ * can read through either.
301
+ */
302
+ export function isTenantScopedSlice(
303
+ sliceEntity: string | undefined,
304
+ sliceRepo: string | undefined,
305
+ ): boolean {
306
+ if (sliceEntity === undefined) return true;
307
+ const declaresTenant = /\btenant\s*:\s*'(?!none')[^']+'/.test(stripComments(sliceEntity));
308
+ if (!declaresTenant) return false;
309
+ if (sliceRepo === undefined) return true;
310
+ return sliceExports(sliceRepo, 'byId') && sliceExports(sliceRepo, 'listByOrg');
311
+ }
312
+
313
+ export function jobFiles(rawName: string, target: JobOptions): readonly GeneratedFile[] {
167
314
  const name = names(rawName);
168
315
  const dir = `${target.surfaceDir}/${target.feature}/jobs`;
316
+ const scoped = isTenantScopedSlice(target.sliceEntity, target.sliceRepo);
169
317
  return [
170
318
  // The job's steps read through `../repo`, which carries `../entity` for its row type. No
171
319
  // policy: a job has no request behind it and evaluates none, so a generated one would be a
172
320
  // file nobody asked for. `x g task` inherits this by composing `jobFiles` below.
173
- ...sliceFoundation(target, ['entity']),
174
- { path: `${dir}/${name.kebab}.ts`, contents: jobSource(name) },
321
+ // Only for the tenant-scoped shape: the neutral job below reads neither module, and a slice
322
+ // this feature does not own the tenancy of is not this generator's to scaffold an entity into.
323
+ ...(scoped ? sliceFoundation(target, ['entity']) : []),
324
+ {
325
+ path: `${dir}/${name.kebab}.ts`,
326
+ contents: scoped ? jobSource(name) : neutralJobSource(name),
327
+ },
175
328
  // `.job.test.ts`, because the gate types a test by its FILENAME: a `jobTest` in a plain
176
329
  // `<name>.test.ts` runs under `unit`, and `x test job` answers X_TEST_NO_FILES in an app that
177
330
  // is full of them. Same lesson `x g route` already carries for `page.e2e.test.ts`.
178
- { path: `${dir}/${name.kebab}.job.test.ts`, contents: jobTest(name) },
331
+ {
332
+ path: `${dir}/${name.kebab}.job.test.ts`,
333
+ contents: scoped ? jobTest(name) : neutralJobTest(name),
334
+ },
179
335
  ];
180
336
  }
181
337
 
182
- export function taskFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] {
338
+ export function taskFiles(rawName: string, target: JobOptions): readonly GeneratedFile[] {
183
339
  const name = names(rawName);
184
340
  const jobName = names(`${rawName}-job`);
185
341
  const dir = `${target.surfaceDir}/${target.feature}/tasks`;
342
+ const scoped = isTenantScopedSlice(target.sliceEntity, target.sliceRepo);
186
343
  return [
187
- { path: `${dir}/${name.kebab}.ts`, contents: taskSource(name, jobName) },
344
+ {
345
+ path: `${dir}/${name.kebab}.ts`,
346
+ contents: scoped ? taskSource(name, jobName) : neutralTaskSource(name, jobName),
347
+ },
188
348
  // A task's test is a `jobTest` too — it drives a queue — so it takes the same suffix.
189
349
  { path: `${dir}/${name.kebab}.job.test.ts`, contents: taskTest(name, jobName) },
190
350
  ...jobFiles(`${rawName}-job`, target),
@@ -23,9 +23,13 @@ const devActor = (
23
23
  // \`X_CONFIG_INVALID\` — which is what a scaffolded app did on its very first \`x dev\`.
24
24
  //
25
25
  // DEVELOPMENT ONLY, and the guard is the point: a viewer that followed this to staging would sign
26
- // every visitor in as an admin. \`bun test\` sets \`NODE_ENV=test\`, so it does not install there
27
- // either — a fixture mints its own actor, and a second one arriving from a cookie would decide
28
- // which actor a test is about.
26
+ // every visitor in as an admin. FAILS CLOSED (\`fallback: 'production'\`) rather than trusting
27
+ // \`tryResolveEnvironment\`'s own default: that default is \`development\`, so a process that named
28
+ // NEITHER \`ULTIMATE_ENV\` nor \`NODE_ENV\` would otherwise read as development too — indistinguishable
29
+ // from the one this file exists to allow. \`x dev\` declares \`ULTIMATE_ENV=development\` for exactly
30
+ // this reason (whenever neither key is already set), so a bare \`x dev\` still installs this viewer;
31
+ // \`bun test\` sets \`NODE_ENV=test\`, so it does not install there either — a fixture mints its own
32
+ // actor, and a second one arriving from a cookie would decide which actor a test is about.
29
33
  //
30
34
  // REPLACE IT with the real thing: resolve a session cookie to a row, and return that actor.
31
35
  // Everything downstream — pages, policies, live subscribers, MCP tools — reads what this returns.
@@ -78,7 +82,7 @@ export const devActorFor = (role: DevRole): Actor => ({
78
82
  export function installDevAuthenticator(
79
83
  env: Readonly<Record<string, string | undefined>> = process.env,
80
84
  ): boolean {
81
- if (tryResolveEnvironment({ env }) !== 'development') return false;
85
+ if (tryResolveEnvironment({ env, fallback: 'production' }) !== 'development') return false;
82
86
  configureAuthenticator((request) => devActorFor(devRoleFrom(request.header('cookie'))));
83
87
  logger.warn('every request is answered as a development viewer', {
84
88
  role: DEFAULT_DEV_ROLE,
@@ -133,6 +137,11 @@ unitTest('it installs in development and in no other environment', () => {
133
137
 
134
138
  expect(installDevAuthenticator({ ULTIMATE_ENV: 'production' })).toBe(false);
135
139
  expect(installDevAuthenticator({ ULTIMATE_ENV: 'staging' })).toBe(false);
140
+ // FAILS CLOSED: a process naming NEITHER key is production here, never the default-development
141
+ // a bare \`tryResolveEnvironment({ env })\` would answer. \`x dev\` is what makes a real \`x dev\`
142
+ // still install this viewer — it declares \`ULTIMATE_ENV=development\` before this module loads,
143
+ // for exactly the process this call simulates having none of.
144
+ expect(installDevAuthenticator({})).toBe(false);
136
145
  expect(configuredAuthenticator()).toBeUndefined();
137
146
 
138
147
  expect(installDevAuthenticator({ ULTIMATE_ENV: 'development' })).toBe(true);
@@ -115,8 +115,12 @@ const listedExports = (code: string): readonly string[] =>
115
115
  */
116
116
  export function sliceExports(source: string, name: string): boolean {
117
117
  const code = stripComments(source);
118
+ // `(?:async\s+)?` before `function`: an `export async function byId` — every repo function
119
+ // `x g entity` scaffolds — matched neither this nor `listedExports`, so a caller checking for
120
+ // `byId`/`listByOrg` on a real repo.ts always read `false`. `async` has no meaning before
121
+ // `class`/`const`/`let`/`var`/`enum`, so it is scoped to `function` only.
118
122
  const declared = new RegExp(
119
- `\\bexport\\s+(?:abstract\\s+)?(?:class|const|let|var|function|enum)\\s+${name}\\b`,
123
+ `\\bexport\\s+(?:abstract\\s+)?(?:class|const|let|var|(?:async\\s+)?function|enum)\\s+${name}\\b`,
120
124
  );
121
125
  return declared.test(code) || listedExports(code).includes(name);
122
126
  }
@@ -0,0 +1,125 @@
1
+ // Single responsibility: which env keys reached THIS `x` process only because Bun auto-loaded
2
+ // `.env.development` / `.env.development.local` at startup — so a `bun test` child this process
3
+ // spawns (`test-shards.ts`, `verify-tests.ts`, `verify-test-run.ts`, `mcp-host.ts`'s `runTests`)
4
+ // does not inherit a key a bare `bun test` would never have seen.
5
+ //
6
+ // PROBED ON BUN 1.4.2, and the probe decided the rule: an ambient env var SHADOWS a dotenv file's
7
+ // own value for the same key (`FOO=dev bun test` with a fixture `.env.test` declaring `FOO=test`
8
+ // still read `FOO=dev` inside the test). So a leaked key is never made safe by a legitimate test
9
+ // file (`.env`, `.env.test`, …) also declaring it — if we left it in place, the leaked value would
10
+ // go on shadowing that file's own value exactly as it shadows `.env.test` above. The only question
11
+ // worth asking is the one `devOnlyLeakedKeys` answers: does this process's CURRENT value match
12
+ // what `.env.development`/`.env.development.local` would have set? If yes, delete it and let the
13
+ // child's own dotenv load (or absence of one) answer instead. If the current value differs, this
14
+ // process's env holds something dotenv did not put there — a real export, CI, `.env.local` — and
15
+ // deleting it is not this function's call to make.
16
+ //
17
+ // KNOWN LIMITATION, stated rather than hidden: a real ambient value that happens to COINCIDE with
18
+ // the dev file's value is indistinguishable from a leak from here — there is no pre-dotenv
19
+ // snapshot to compare against. This function resolves that ambiguity toward closing the leak.
20
+ //
21
+ // A SECOND PARSER, not a call to `env-example.ts`'s `parseEnvKeys`: that function is deliberately
22
+ // keys-only ("half of them are placeholders" — it feeds `.env.example` rendering, where a secret's
23
+ // value must never appear), and widening it to return values would change what a template
24
+ // generator ships. This one exists to COMPARE values, a different job with a different file.
25
+
26
+ import { readFileSync } from 'node:fs'; // why: Bun ships no synchronous file read with a graceful-missing return.
27
+ // why: Bun exposes no path-join primitive.
28
+ import { join } from 'node:path';
29
+
30
+ /** Bun's own grammar, matched against `env-example.ts`'s `ENV_KEY_RE` (kept separate: see header). */
31
+ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
32
+
33
+ /**
34
+ * One dotenv file's key/value pairs. A `Map`, not an object literal: a key of `constructor` or
35
+ * `__proto__` passes `ENV_KEY_RE` and reads back as a function on `Object.prototype`, the exact
36
+ * defect this repo's `verify-tests.ts` header names thirteen instances of.
37
+ *
38
+ * Quoting: a value opening with `"` or `'` runs to its closing quote (or end of value if the
39
+ * dotenv file never closes it), `#` included — Bun does not stop a quoted value at an internal
40
+ * `#`. An unquoted value stops at the first `#`, which is where an inline comment starts.
41
+ */
42
+ export function parseDotenvValues(text: string): ReadonlyMap<string, string> {
43
+ const values = new Map<string, string>();
44
+ for (const raw of text.split('\n')) {
45
+ const line = raw.trim().replace(/^export\s+/, '');
46
+ if (line === '' || line.startsWith('#')) continue;
47
+ const separator = line.indexOf('=');
48
+ if (separator <= 0) continue;
49
+ const key = line.slice(0, separator).trim();
50
+ if (!ENV_KEY_RE.test(key)) continue;
51
+ const rest = line.slice(separator + 1).trim();
52
+ const quote = rest.startsWith('"') || rest.startsWith("'") ? rest[0] : undefined;
53
+ if (quote === undefined) {
54
+ const hash = rest.indexOf('#');
55
+ values.set(key, (hash >= 0 ? rest.slice(0, hash) : rest).trim());
56
+ continue;
57
+ }
58
+ const closing = rest.indexOf(quote, 1);
59
+ values.set(key, closing >= 0 ? rest.slice(1, closing) : rest.slice(1));
60
+ }
61
+ return values;
62
+ }
63
+
64
+ export interface DevOnlyLeakInput {
65
+ /** `.env.development`'s text, or `''` when the file does not exist. */
66
+ readonly devText: string;
67
+ /** `.env.development.local`'s text, or `''` — wins over `devText` for a shared key, Bun's own precedence. */
68
+ readonly devLocalText: string;
69
+ /** This process's environment, as `exec.ts` would spawn a child with it (before any override). */
70
+ readonly env: Readonly<Record<string, string | undefined>>;
71
+ }
72
+
73
+ /**
74
+ * The pure decision, no filesystem: every key `.env.development`/`.env.development.local` would
75
+ * set, where `env`'s CURRENT value for that key is exactly what the file would have set. See this
76
+ * file's header for why a legitimate test file declaring the same key does not exempt it.
77
+ */
78
+ export function devOnlyLeakedKeys(input: DevOnlyLeakInput): readonly string[] {
79
+ const merged = new Map(parseDotenvValues(input.devText));
80
+ for (const [key, value] of parseDotenvValues(input.devLocalText)) merged.set(key, value);
81
+ const leaked: string[] = [];
82
+ for (const [key, devValue] of merged) {
83
+ // `Object.hasOwn` FIRST: `key` is data (a name out of a dotenv file), and `env['constructor']`
84
+ // on a plain object answers `Object.prototype`'s member, not `undefined` — the rule
85
+ // `scripts/proto-index.ts` ratchets, with its own sanctioned repair.
86
+ if (Object.hasOwn(input.env, key) && input.env[key] === devValue) leaked.push(key);
87
+ }
88
+ return leaked;
89
+ }
90
+
91
+ /** A missing dotenv file is the common case (no `.env.development.local` in most checkouts). */
92
+ function readIfPresent(path: string): string {
93
+ try {
94
+ return readFileSync(path, 'utf8');
95
+ } catch {
96
+ return '';
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The env override a `bun test` child spawned FROM `root` should get: every dev-only leaked key
102
+ * deleted. `exec.ts`'s `ExecOptions.env` reads an `undefined` value as "unset for this child,
103
+ * even though the parent has it" — see that file for why the merge could not otherwise express
104
+ * a deletion.
105
+ *
106
+ * `root` is the app/repo root the caller already resolved — NOT necessarily `process.cwd()`, which
107
+ * is what Bun actually auto-loaded `.env.development` relative to at this process's own startup.
108
+ * The two agree for every command that boots from the repo/app root, which is every one of them
109
+ * today; a future command invoked from a subdirectory would fail SAFE here (the file this reads
110
+ * would differ from the one Bun loaded, values would not match, and nothing gets stripped) rather
111
+ * than stripping the wrong key.
112
+ */
113
+ export function testEnvOverrides(
114
+ root: string,
115
+ env: Readonly<Record<string, string | undefined>>,
116
+ ): Readonly<Record<string, string | undefined>> {
117
+ const leaked = devOnlyLeakedKeys({
118
+ devText: readIfPresent(join(root, '.env.development')),
119
+ devLocalText: readIfPresent(join(root, '.env.development.local')),
120
+ env,
121
+ });
122
+ const overrides: Record<string, string | undefined> = {};
123
+ for (const key of leaked) overrides[key] = undefined;
124
+ return overrides;
125
+ }