@ultimat3/cli 6.0.0 → 7.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.
@@ -0,0 +1,279 @@
1
+ // The slice's client entry: `x g resource <name>` emits its form as an ISLAND, because that is the
2
+ // one shape the framework compiles for a browser. A plain `.tsx` with a signal and an `onSubmit`
3
+ // is not a smaller version of this — the island glob never discovers it, and a server render drops
4
+ // every `on*` prop (`packages/render/src/html.ts`) and reads each signal exactly once.
5
+
6
+ // Bun ships no path API, and the one arithmetic this file does is a specifier: the island's path
7
+ // seen from the page's directory, which `island-bundle.ts` resolves the same way at build time.
8
+ import { posix } from 'node:path';
9
+ import { upToAppRoot } from './island';
10
+ import type { GeneratedFile, NameSet } from './naming';
11
+
12
+ /**
13
+ * What the PAGE has to write, which is not what the island's own directory would suggest.
14
+ *
15
+ * `island({ src })` resolves against the route file, and `x g resource widget` writes its page at
16
+ * `apps/web/app/widgets/` while the island lands in `apps/web/app/widget/` — so the `'./…'` this
17
+ * file used to print resolved to `apps/web/app/widgets/widget-form.island.tsx`, a path the build
18
+ * never bundles, and an author following the comment got `X_ISLAND_INVALID`. Derived from the two
19
+ * directories rather than written down, so it cannot disagree with where the files actually go.
20
+ */
21
+ export const formIslandSpecifier = (feature: NameSet, dir: string, pageDir: string): string => {
22
+ const relative = posix.join(posix.relative(pageDir, dir), `${feature.kebab}-form.island.tsx`);
23
+ return relative.startsWith('.') ? relative : `./${relative}`;
24
+ };
25
+
26
+ const formIslandSource = (
27
+ feature: NameSet,
28
+ specifier: string,
29
+ ): string => `// ${feature.pascal}Form: the only module of the ${feature.kebab} slice a browser downloads.
30
+ //
31
+ // The page names this file by SPECIFIER, never by import — and the specifier is resolved against
32
+ // the PAGE, which is one directory across from this one:
33
+ // const ${feature.pascal}Form = island({
34
+ // src: '${specifier}',
35
+ // props: ['endpoint', 'locale', 'labels'],
36
+ // });
37
+ // <${feature.pascal}Form endpoint={derivePath('create${feature.pascal}').path} locale={locale} labels={labels} />
38
+ // A string has no import edge, so the page's bundle graph stays the page's (axiom 6).
39
+
40
+ import { Button, Form, Input, setSolidRuntime, UiProvider } from '@ultimat3/ui';
41
+ import type { JSX } from 'solid-js';
42
+ import * as solidRuntime from 'solid-js';
43
+ import { createSignal } from 'solid-js';
44
+ import { render } from 'solid-js/web';
45
+ import styles from './ui.module.scss';
46
+
47
+ /** Every string this module renders, and the path it posts to. An island's props cross the seam as
48
+ * JSON inside the document, so \`t()\`'s catalog cannot travel — the server translates. */
49
+ export interface ${feature.pascal}FormProps {
50
+ /** \`derivePath('create${feature.pascal}').path\`, minted on the server: one namer for the route. */
51
+ readonly endpoint: string;
52
+ /** The request's own locale. A browser has no ambient one the server ever agreed to. */
53
+ readonly locale: string;
54
+ readonly labels: {
55
+ readonly title: string;
56
+ readonly submit: string;
57
+ readonly saved: string;
58
+ readonly retry: string;
59
+ };
60
+ }
61
+
62
+ type SaveState = 'idle' | 'saved' | 'failed';
63
+
64
+ /**
65
+ * Presentation only: the action this submits to owns validation server-side, so the form never
66
+ * re-implements the invariant — a blank title fails at the boundary, not in the DOM.
67
+ *
68
+ * A plain \`fetch\` to the path the server minted, not the typed client: \`rpc()\` pulls
69
+ * \`@ultimat3/action\` into the chunk, which is larger than everything else here put together. The
70
+ * naming rule is still the framework's; only the transport is second.
71
+ */
72
+ function ${feature.pascal}FormBody(props: ${feature.pascal}FormProps): JSX.Element {
73
+ const [title, setTitle] = createSignal('');
74
+ const [state, setState] = createSignal<SaveState>('idle');
75
+
76
+ // A rejected \`fetch\` — offline, DNS, an aborted request — is the same OUTCOME as a refused one,
77
+ // and it is the one \`retry\` exists for. Without the catch, \`setState\` is never reached: the
78
+ // status line stays empty, and the rejection escapes the \`void send()\` below as an unhandled one.
79
+ const send = async (): Promise<void> => {
80
+ try {
81
+ const response = await fetch(props.endpoint, {
82
+ method: 'POST',
83
+ headers: { 'content-type': 'application/json' },
84
+ body: JSON.stringify({ title: title() }),
85
+ });
86
+ setState(response.ok ? 'saved' : 'failed');
87
+ } catch {
88
+ setState('failed');
89
+ }
90
+ };
91
+
92
+ const status = (): string => {
93
+ if (state() === 'saved') return props.labels.saved;
94
+ return state() === 'failed' ? props.labels.retry : '';
95
+ };
96
+
97
+ return (
98
+ <Form
99
+ class={styles.item}
100
+ onSubmit={(event) => {
101
+ event.preventDefault();
102
+ void send();
103
+ }}
104
+ >
105
+ <Input
106
+ aria-label={props.labels.title}
107
+ value={title()}
108
+ onInput={(event) => setTitle(event.currentTarget.value)}
109
+ />
110
+ <Button type="submit">{props.labels.submit}</Button>
111
+ <p data-role="status" role="status" aria-live="polite">
112
+ {status()}
113
+ </p>
114
+ </Form>
115
+ );
116
+ }
117
+
118
+ /**
119
+ * The one export the hydration runtime calls — \`import(entry).then((m) => m.mount(el, props))\`.
120
+ *
121
+ * \`setSolidRuntime\` comes FIRST and it is not optional: \`@ultimat3/ui\` imports *types* from
122
+ * solid-js and never a runtime, so the reactive graph a component reaches is the one an entry
123
+ * registers. Delete the line and the first \`<UiProvider>\` render throws X_UI_RUNTIME_MISSING —
124
+ * loud on purpose, because a DOM render that lost its runtime is a theme toggle that does nothing.
125
+ *
126
+ * The shell is cleared first: Solid's \`render\` APPENDS when the container already has children,
127
+ * so without it the server's markup stays on screen above a second, live copy of the same thing.
128
+ */
129
+ export function mount(el: HTMLElement, props: ${feature.pascal}FormProps): void {
130
+ setSolidRuntime(solidRuntime);
131
+ el.textContent = '';
132
+ render(
133
+ () => (
134
+ <UiProvider locale={props.locale}>
135
+ <${feature.pascal}FormBody {...props} />
136
+ </UiProvider>
137
+ ),
138
+ el,
139
+ );
140
+ }
141
+ `;
142
+
143
+ const formIslandTest = (
144
+ feature: NameSet,
145
+ dir: string,
146
+ ): string => `// The form the browser actually runs. \`mountIsland\` builds this entry with the same
147
+ // \`buildIslands\` that \`x build\` and \`x dev\` use, imports the emitted chunk the way the hydration
148
+ // runtime does, and drives \`mount\` against a DOM small enough to read.
149
+ //
150
+ // It is the test that keeps this file a CLIENT entry. A generated form that only typechecks is
151
+ // what shipped before: server-rendered, every \`on*\` prop dropped, every signal read once.
152
+
153
+ import { join } from 'node:path';
154
+ import { buildIslands } from '@ultimat3/cli';
155
+ import {
156
+ afterAll,
157
+ beforeAll,
158
+ describe,
159
+ expect,
160
+ type FakeElement,
161
+ type MountedIsland,
162
+ mountIsland,
163
+ test,
164
+ } from '@ultimat3/testing';
165
+
166
+ const APP_ROOT = join(import.meta.dir, ${upToAppRoot(dir)});
167
+ const ISLAND = '${dir}/${feature.kebab}-form.island.tsx';
168
+ const ENDPOINT = '/api/${feature.kebab}/create-${feature.kebab}';
169
+
170
+ const LABELS = { title: 'Title', submit: 'Save', saved: 'Saved', retry: 'Try again' };
171
+
172
+ const calls: { url: string; body: Record<string, unknown> }[] = [];
173
+
174
+ /** One stub, both outcomes: the mount costs seconds, so the network's answer is a switch. */
175
+ let networkFails = false;
176
+
177
+ let mounted: MountedIsland;
178
+
179
+ // The build is a Babel pass plus a browser bundle — seconds, not milliseconds. It lives in
180
+ // \`beforeAll\` with its own timeout because \`test\` takes no third argument: fixtures are resolved
181
+ // per case, so the slow work goes where it can be given one and every case shares the result.
182
+ beforeAll(async () => {
183
+ mounted = await mountIsland({
184
+ build: buildIslands,
185
+ root: APP_ROOT,
186
+ file: ISLAND,
187
+ props: { endpoint: ENDPOINT, locale: 'en', labels: LABELS },
188
+ // What the server rendered inside the island's wrapper. \`mount\` replaces it.
189
+ shell: '<p>Loading</p>',
190
+ globals: {
191
+ fetch: (url: string, init: { body: string }): Promise<{ ok: boolean }> => {
192
+ calls.push({ url, body: JSON.parse(init.body) as Record<string, unknown> });
193
+ // What a browser rejects with when there is no network. Not a response: \`response.ok\`
194
+ // is never read on this path, which is exactly why the form has to catch it.
195
+ return networkFails
196
+ ? Promise.reject(new TypeError('Failed to fetch'))
197
+ : Promise.resolve({ ok: true });
198
+ },
199
+ },
200
+ });
201
+ }, 60_000);
202
+
203
+ // The fake \`document\` is process-global: left installed it reaches every LATER FILE in the run.
204
+ //
205
+ // \`?.\` on a binding the type says is always set: TypeScript's definite-assignment analysis does not
206
+ // cross the \`beforeAll\` closure, so a setup that REJECTED leaves this undefined at run time — and
207
+ // bun runs \`afterAll\` regardless. Unguarded, the build failure is followed by a \`TypeError:
208
+ // undefined is not an object\` that says nothing, and that second line is the one a tail reads.
209
+ // Nothing is skipped by the guard: \`mountIsland\` restores the process itself when a mount throws.
210
+ afterAll(() => {
211
+ mounted?.[Symbol.dispose]();
212
+ });
213
+
214
+ /**
215
+ * One mount, driven as a session: the cases below run in order against the same island, because
216
+ * building the real chunk costs seconds and repeating it per case would pay them for state each
217
+ * case sets up anyway. What each one asserts is independent.
218
+ */
219
+ describe('the ${feature.kebab} form island', () => {
220
+ test('mount replaces the server shell with the editor', () => {
221
+ expect(mounted.find('p')?.getAttribute('data-role')).toBe('status');
222
+ // Solid compiles to real DOM calls; a chunk that fell back to the classic React factory names
223
+ // a global that is not in it, and \`Bun.build\` answers \`success: true\` over that all the same.
224
+ expect(mounted.code).not.toMatch(/\\bReact\\b/);
225
+ });
226
+
227
+ test('the field tracks, and submit posts what was typed', async () => {
228
+ const field: FakeElement | null = mounted.find('input');
229
+ expect(field).not.toBeNull();
230
+ if (field !== null) field.value = 'First ${feature.camel}';
231
+ // \`false\` means no handler ran — an island whose onInput never reached the DOM looks
232
+ // identical to a selector typo otherwise.
233
+ expect(mounted.fire(field, 'input')).toBe(true);
234
+ expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
235
+ await Promise.resolve();
236
+
237
+ expect(calls).toEqual([{ url: ENDPOINT, body: { title: 'First ${feature.camel}' } }]);
238
+ });
239
+
240
+ test('the status line answers the response', () => {
241
+ // The signal reached the DOM: an eager JSX factory renders '' here and never runs again.
242
+ expect(mounted.text('[data-role="status"]')).toBe(LABELS.saved);
243
+ });
244
+
245
+ test('a request that never got a response still reaches retry', async () => {
246
+ // The outcome \`retry\` is FOR. A \`fetch\` that rejects reaches no \`response.ok\`, so without
247
+ // the catch in \`send\` the status line stays on its last value and the rejection escapes.
248
+ networkFails = true;
249
+ expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
250
+ await Promise.resolve();
251
+ await Promise.resolve();
252
+
253
+ expect(mounted.text('[data-role="status"]')).toBe(LABELS.retry);
254
+ });
255
+ });
256
+ `;
257
+
258
+ /**
259
+ * The slice's form, as the one client shape: `<dir>/<feature>-form.island.tsx` plus its test.
260
+ *
261
+ * `pageDir` is the directory of the page that declares it — the caller's, because only the caller
262
+ * runs both generators and knows where the other one put its file.
263
+ */
264
+ export function formIslandFiles(
265
+ feature: NameSet,
266
+ dir: string,
267
+ pageDir: string,
268
+ ): readonly GeneratedFile[] {
269
+ return [
270
+ {
271
+ path: `${dir}/${feature.kebab}-form.island.tsx`,
272
+ contents: formIslandSource(feature, formIslandSpecifier(feature, dir, pageDir)),
273
+ },
274
+ {
275
+ path: `${dir}/${feature.kebab}-form.island.test.ts`,
276
+ contents: formIslandTest(feature, dir),
277
+ },
278
+ ];
279
+ }
@@ -13,7 +13,8 @@ import type { GeneratedFile, NameSet } from './naming';
13
13
  import { names, pascal } from './naming';
14
14
  import { policyFiles } from './policy';
15
15
  import { queryFiles } from './query';
16
- import { routeFiles } from './route';
16
+ import { formIslandFiles } from './resource-form-island';
17
+ import { routeDir, routeFiles } from './route';
17
18
 
18
19
  const serviceSource = (
19
20
  feature: NameSet,
@@ -142,40 +143,6 @@ export function ${feature.pascal}Card(props: ${feature.pascal}CardProps) {${tran
142
143
  }
143
144
  `;
144
145
 
145
- const formSource = (
146
- feature: NameSet,
147
- module: string | undefined,
148
- ): string => `// Presentation only: the mutator this submits to owns validation server-side, so this form
149
- // never re-implements the invariant — a blank title fails at the boundary, not in the DOM.
150
-
151
- ${catalogImport(module)}
152
- import { createSignal } from 'solid-js';
153
- import styles from '../ui.module.scss';
154
-
155
- export interface ${feature.pascal}FormProps {
156
- readonly onSubmit: (title: string) => void;
157
- }
158
-
159
- export function ${feature.pascal}Form(props: ${feature.pascal}FormProps) {
160
- const [title, setTitle] = createSignal('');${translatorBinding(module)}
161
- return (
162
- <form
163
- class={styles.item}
164
- onSubmit={(event) => {
165
- event.preventDefault();
166
- props.onSubmit(title());
167
- }}
168
- >
169
- <label>
170
- {t('app.${feature.kebab}.titleLabel')}
171
- <input value={title()} onInput={(event) => setTitle(event.currentTarget.value)} />
172
- </label>
173
- <button type="submit">{t('app.${feature.kebab}.submit')}</button>
174
- </form>
175
- );
176
- }
177
- `;
178
-
179
146
  // `admin.<feature>.title` is always here, `--admin` or not: `defineAdmin()` resolves that key the
180
147
  // moment anyone writes the override, and a missing key renders ⟦key⟧ and fails the i18n gate,
181
148
  // while an unused key is only ever reported (`auditCatalogs` fails on `missing`, never `unused`).
@@ -185,6 +152,8 @@ const catalogSource = (feature: NameSet): string =>
185
152
  [`app.${feature.kebab}.updated`]: 'Last updated',
186
153
  [`app.${feature.kebab}.titleLabel`]: 'Title',
187
154
  [`app.${feature.kebab}.submit`]: 'Save',
155
+ [`app.${feature.kebab}.saved`]: 'Saved',
156
+ [`app.${feature.kebab}.retry`]: 'Try again',
188
157
  [`admin.${feature.kebab}.title`]: pascal(feature.plural),
189
158
  });
190
159
 
@@ -204,6 +173,10 @@ export function resourceFiles(rawName: string, target: ResourceOptions): readonl
204
173
  const feature = names(rawName);
205
174
  const slice: FeatureTarget = { surfaceDir: target.surfaceDir, feature: feature.kebab };
206
175
  const dir = `${slice.surfaceDir}/${slice.feature}`;
176
+ // The page this same call writes, from the function that decides where a route goes — the island
177
+ // specifier is resolved against it, so re-deriving the path here would be two answers to one
178
+ // question and only one of them reaches `routeFiles`.
179
+ const pageDir = routeDir('app', feature.pluralKebab);
207
180
  const locales = resolveLocales(target.locales);
208
181
  return [
209
182
  ...entityFiles(rawName, slice),
@@ -220,18 +193,24 @@ export function resourceFiles(rawName: string, target: ResourceOptions): readonl
220
193
  path: `${dir}/ui/${feature.kebab}-card.tsx`,
221
194
  contents: cardSource(feature, target.catalogModule),
222
195
  },
223
- {
224
- path: `${dir}/ui/${feature.kebab}-form.tsx`,
225
- contents: formSource(feature, target.catalogModule),
226
- },
196
+ ...formIslandFiles(feature, dir, pageDir),
227
197
  ...locales.map((locale) => ({
228
198
  path: catalogPath(locale),
229
199
  contents: catalogSource(feature),
230
200
  merge: 'json' as const,
231
201
  })),
232
- // Always an app route: a slice ships a live query, a form and actions, and `generate()`
202
+ // Always an app route: a slice ships a live query, a form island and actions, and `generate()`
233
203
  // refuses `--surface site` for a resource rather than emit them behind a 0kb budget.
234
- ...routeFiles(feature.pluralKebab, { surface: 'app', locales }),
204
+ //
205
+ // `catalogModule` travels with it. It did not, so the page a RESOURCE writes imported `t` from
206
+ // `@ultimat3/i18n` while the components beside it imported `useT` from the app's own catalog
207
+ // module — one command, two idioms, and the framework-import one is issue #249's: it renders
208
+ // strings while depending on nothing that registers them.
209
+ ...routeFiles(feature.pluralKebab, {
210
+ surface: 'app',
211
+ locales,
212
+ ...(target.catalogModule === undefined ? {} : { catalogModule: target.catalogModule }),
213
+ }),
235
214
  ...(target.admin === true ? adminFiles(rawName, slice) : []),
236
215
  ];
237
216
  }
@@ -19,6 +19,14 @@ export type Surface = 'site' | 'app';
19
19
  * that was absent from `x routes`, from the manifest and from `budgets`. Ship the mode that works.
20
20
  */
21
21
  const RENDER: Record<Surface, string> = { site: 'isr', app: 'ssr' };
22
+ /**
23
+ * `app` is `'visible'` on a page the generator writes with no island, and that is not an oversight.
24
+ * A declared strategy costs nothing when there is nothing to hydrate — `hydrateRuntime` is emitted
25
+ * per island DIRECTIVE and answers `''` for a page with none (`packages/render/src/hydrate.ts`), so
26
+ * `x routes` reads `visible` while the document ships 0 bytes. What it buys is that adding the
27
+ * first island is one edit: a declared `'never'` beside an island is `X_ISLAND_NOT_HYDRATED`, which
28
+ * `defineRoute` refuses on purpose, so the scaffold would break at the moment it is first used.
29
+ */
22
30
  const HYDRATE: Record<Surface, string> = { site: 'never', app: 'visible' };
23
31
  const OFFLINE: Record<Surface, string> = { site: 'precache', app: 'runtime' };
24
32
  /**
@@ -66,7 +74,12 @@ export const routeParams = (path: string): readonly string[] =>
66
74
  return name === undefined ? [] : [name];
67
75
  });
68
76
 
69
- const routeDir = (surface: Surface, path: string): string =>
77
+ /**
78
+ * Where the page lands. Exported because an island's `src` is resolved relative to the PAGE, and
79
+ * only this function knows where `x g resource`'s page went — a second copy of the formula in the
80
+ * resource generator is how the specifier it prints stops matching the file it writes.
81
+ */
82
+ export const routeDir = (surface: Surface, path: string): string =>
70
83
  `apps/web/${surface}/${segmentsOf(path).join('/')}`;
71
84
 
72
85
  /**
@@ -136,6 +136,8 @@ export const config = defineRoute({
136
136
  // therefore failed x routes with X_ROUTE_MODE_INVALID on the first run, printing a fix nobody
137
137
  // could follow. Ship the mode that works. Async data needs no boundary: await it in the page.
138
138
  render: 'ssr',
139
+ // Stated with no island on the page, deliberately and for free — \`apps/admin/app/admin/page.tsx\`
140
+ // carries the reason.
139
141
  hydrate: 'visible',
140
142
  offline: 'runtime',
141
143
  // Auth is a policy, never a route-local flag: one authz system, evaluated everywhere.
@@ -325,10 +327,16 @@ import { defineRoute } from '@ultimat3/render';
325
327
 
326
328
  export const config = defineRoute({
327
329
  render: 'ssr',
330
+ // Stated on a page whose body is one \`<h1>\`, and it costs nothing: the hydration runtime is
331
+ // emitted per island DIRECTIVE, so \`hydrateRuntime([])\` is \`''\` and this document ships 0 bytes
332
+ // (\`packages/render/src/hydrate.ts\`). It buys the first island being ONE file's edit —
333
+ // \`hydrate: 'never'\` beside an island is \`X_ISLAND_NOT_HYDRATED\`, which defineRoute refuses.
328
334
  hydrate: 'idle',
329
335
  offline: 'network-only',
330
- // Behind auth, and \`ssr\` is the one mode that can be: it renders per request, so the guard runs
331
- // on the server before the page does. \`static\` and \`isr\` refuse a policy outright.
336
+ // Behind auth, so the mode has to render per request: \`ssr\` and \`stream\` both do, and both take
337
+ // a \`policy\`. \`static\` and \`isr\` refuse one outright — a file on disk has no actor to decide
338
+ // against, and an ISR document is cached per URL, so the first actor's HTML would be served to
339
+ // every later one who passes the same policy.
332
340
  policy: { permission: 'admin:read' },
333
341
  budget: { js: '120kb' },
334
342
  meta: ({ t }) => ({ title: t('admin.home.title'), description: t('admin.home.description') }),
@@ -394,6 +402,12 @@ const prerender =
394
402
  // whatever its mode, so \`x verify\`'s \`budgets\` step has a number for it. \`unmeasured\` is the list
395
403
  // this build could not render — each one is an X_BUDGET_UNMEASURED at the gate, and this is where
396
404
  // the reason is.
405
+ //
406
+ // It writes the whole of \`pages\` and \`skipped\`, never a COUNT of either. A count is what let a
407
+ // partial artifact read as a complete one: someone pointed a screenshot tool at \`.x/static\` and
408
+ // filed "the island did not mount" against a route that had never been emitted (issue #242). Each
409
+ // skipped route carries its own \`reason\` and \`why\`, and \`report\` is where the same inventory
410
+ // landed on disk — which is what \`x build --target static --json\` reads back.
397
411
 
398
412
  import { join } from 'node:path';
399
413
  import { prerenderSite } from '@ultimat3/cli';
@@ -410,7 +424,7 @@ const origin = Bun.env.SITE_ORIGIN;
410
424
  if (import.meta.main) {
411
425
  const report = await prerenderSite({ root, out, ...(origin === undefined ? {} : { origin }) });
412
426
  await Bun.stdout.write(
413
- \`\${JSON.stringify({ ok: true, out: report.out, pages: report.pages.length, skipped: report.skipped, unmeasured: report.unmeasured })}\\n\`,
427
+ \`\${JSON.stringify({ ok: true, out: report.out, emitted: report.pages, skipped: report.skipped, unmeasured: report.unmeasured, report: report.report })}\\n\`,
414
428
  );
415
429
  }
416
430
  `;
@@ -13,6 +13,37 @@ import { packageShapeFiles, workspacePackageJson } from './scaffold-package-shap
13
13
 
14
14
  const DESCRIPTION = 'Entity re-exports and SQL migrations, no business logic';
15
15
 
16
+ /**
17
+ * The example slice's entity lives in `apps/web/app/post/`, so `src/schema.ts` re-exports it from
18
+ * there — an edge this manifest has to declare or it exists only inside the root tsconfig's
19
+ * `paths`, where `bun --filter` and every change-detection tool are blind to it
20
+ * (`X_WORKSPACE_DEP_UNDECLARED`). Written here rather than through `workspacePackageJson` for the
21
+ * reason `scaffold-i18n.ts` states: that helper is the dependency-free shape.
22
+ *
23
+ * Under `--no-example` there is no entity and no import, so there is no dependency either: a pin
24
+ * for an edge the package does not have is the same lie in the other direction.
25
+ */
26
+ const dbPackage = (app: NameSet, example: boolean): string =>
27
+ example
28
+ ? `{
29
+ "name": "@${app.kebab}/db",
30
+ "version": "0.0.0",
31
+ "private": true,
32
+ "type": "module",
33
+ "description": "${DESCRIPTION}",
34
+ "exports": {
35
+ ".": "./src/index.ts"
36
+ },
37
+ "scripts": {
38
+ "typecheck": "tsc --noEmit -p ../../tsconfig.json"
39
+ },
40
+ "dependencies": {
41
+ "@${app.kebab}/web": "0.0.0"
42
+ }
43
+ }
44
+ `
45
+ : workspacePackageJson(app, 'db', DESCRIPTION);
46
+
16
47
  const dbIndex =
17
48
  (): string => `// Schema and migrations only — no business logic lives in this package. The client itself is
18
49
  // @ultimat3/db's: one connection pool, sized by ROLE, shared by every package in the app.
@@ -101,7 +132,7 @@ export const ${app.camel}Seed = defineSeed('${app.kebab}', async () => {
101
132
 
102
133
  /** Every file the `packages/db` workspace ships, in the order `x new` writes them. */
103
134
  export const dbPackageFiles = (app: NameSet, example: boolean): readonly GeneratedFile[] => [
104
- { path: 'packages/db/package.json', contents: workspacePackageJson(app, 'db', DESCRIPTION) },
135
+ { path: 'packages/db/package.json', contents: dbPackage(app, example) },
105
136
  ...packageShapeFiles(app, 'db', DESCRIPTION),
106
137
  { path: 'packages/db/src/index.ts', contents: dbIndex() },
107
138
  { path: 'packages/db/src/schema.ts', contents: dbSchema(app, example) },
@@ -3,10 +3,43 @@
3
3
  // itself.
4
4
 
5
5
  import type { GeneratedFile, NameSet } from './naming';
6
- import { packageShapeFiles, workspacePackageJson } from './scaffold-package-shape';
6
+ import { packageShapeFiles } from './scaffold-package-shape';
7
7
 
8
8
  const DESCRIPTION = "The app's own MCP tools";
9
9
 
10
+ /**
11
+ * Writes its own manifest, for the reason `scaffold-i18n.ts` does: `workspacePackageJson` is the
12
+ * dependency-free shape, and this package has a real dependency — `src/index.ts` below imports the
13
+ * app's actions out of `apps/web/api`, because `registerActions` has to see them.
14
+ *
15
+ * The edge points AT the app, which is unusual and correct. `apps/web` is a workspace like any
16
+ * other; reversing it would put the tool catalog upstream of the actions it projects. Declared
17
+ * rather than left to the root tsconfig's `paths`: an undeclared edge resolves for `tsc` and for
18
+ * nothing else — not for `bun --filter` ordering, not for any tool asking what a change affects
19
+ * (`X_WORKSPACE_DEP_UNDECLARED`).
20
+ *
21
+ * `"0.0.0"` and not `workspace:*`: it is the version `apps/web` really carries, which is what
22
+ * `checkLockstep` compares a sibling pin against, and it is the one spelling every other manifest
23
+ * `x new` writes already uses.
24
+ */
25
+ const mcpPackage = (app: NameSet): string => `{
26
+ "name": "@${app.kebab}/mcp",
27
+ "version": "0.0.0",
28
+ "private": true,
29
+ "type": "module",
30
+ "description": "${DESCRIPTION}",
31
+ "exports": {
32
+ ".": "./src/index.ts"
33
+ },
34
+ "scripts": {
35
+ "typecheck": "tsc --noEmit -p ../../tsconfig.json"
36
+ },
37
+ "dependencies": {
38
+ "@${app.kebab}/web": "0.0.0"
39
+ }
40
+ }
41
+ `;
42
+
10
43
  const mcpIndex = (
11
44
  app: NameSet,
12
45
  ): string => `// The app's own MCP tools. Every action with mcp.expose is already a tool; add app-specific
@@ -42,7 +75,7 @@ unitTest('the app exposes its actions as MCP tools', () => {
42
75
 
43
76
  /** Every file the `packages/mcp` workspace ships, in the order `x new` writes them. */
44
77
  export const mcpPackageFiles = (app: NameSet): readonly GeneratedFile[] => [
45
- { path: 'packages/mcp/package.json', contents: workspacePackageJson(app, 'mcp', DESCRIPTION) },
78
+ { path: 'packages/mcp/package.json', contents: mcpPackage(app) },
46
79
  ...packageShapeFiles(app, 'mcp', DESCRIPTION),
47
80
  { path: 'packages/mcp/src/index.ts', contents: mcpIndex(app) },
48
81
  { path: 'packages/mcp/src/index.test.ts', contents: mcpTest() },
@@ -50,8 +50,13 @@ export const packageShapeFiles = (
50
50
 
51
51
  /**
52
52
  * The manifest every scaffolded `packages/*` carries. Private, versionless-by-convention and
53
- * dependency-free: these packages only re-export a framework package's types, so the one that does
54
- * name a dependency writes its own (`scaffold-i18n.ts`, and it says why). Lives beside
53
+ * dependency-free: these packages only re-export a framework package's types. Three now DO name a
54
+ * dependency and each writes its own manifest — `scaffold-i18n.ts`, `scaffold-mcp-package.ts` and
55
+ * `scaffold-db-package.ts` (the last only under `--example`, which is the branch that emits the
56
+ * import). Each says why at its own site. They write their own rather than taking a `dependencies`
57
+ * parameter here because the edge is a fact about the SOURCE that template emits, and a parameter
58
+ * would let a caller declare an edge its generated code does not have — which is the drift
59
+ * `X_WORKSPACE_DEP_UNDECLARED` exists to catch, pointed the other way. Lives beside
55
60
  * `packageShapeFiles` because every caller of one calls the other.
56
61
  */
57
62
  export const workspacePackageJson = (app: NameSet, name: string, description: string): string => `{
@@ -3,6 +3,7 @@
3
3
  // cmd-test.ts because a printed reproduction is only true if it carries every input to the split —
4
4
  // that rule is this file's, and argv parsing is that one's.
5
5
 
6
+ import type { AffectedSelection } from './affected';
6
7
  import { docsFor } from './error-codes';
7
8
  import type { Runner } from './exec';
8
9
  import { execOutput } from './exec';
@@ -72,12 +73,18 @@ export interface ReproduceOptions {
72
73
  readonly type?: TestType;
73
74
  /** Files `--sample` kept, so the rerun samples the same corpus instead of the whole type. */
74
75
  readonly sample?: number;
76
+ /**
77
+ * The `--affected` narrowing, when there was one. The fourth input to the split and the one most
78
+ * easily forgotten: `--affected` decides which files exist to shard at all, so a rerun without it
79
+ * re-splits the WHOLE corpus and its shard 2 is a different shard 2.
80
+ */
81
+ readonly affected?: AffectedSelection;
75
82
  }
76
83
 
77
84
  /**
78
- * Every input to the split, printed back. The type and `--filter` decide which files exist to
79
- * shard, `--sample` decides how many of them survive, `--workers` decides the bins — drop any one
80
- * and the command still runs, over a different file set, which reproduces nothing.
85
+ * Every input to the split, printed back. The type, `--filter` and `--affected` decide which files
86
+ * exist to shard, `--sample` decides how many of them survive, `--workers` decides the bins — drop
87
+ * any one and the command still runs, over a different file set, which reproduces nothing.
81
88
  */
82
89
  export function reproduceFor(shard: Shard, options: ReproduceOptions): string {
83
90
  return [
@@ -85,6 +92,12 @@ export function reproduceFor(shard: Shard, options: ReproduceOptions): string {
85
92
  ...(options.type === undefined ? [] : [quoteArg(options.type)]),
86
93
  ...(options.filter === undefined ? [] : ['--filter', quoteArg(options.filter)]),
87
94
  ...(options.sample === undefined ? [] : ['--sample', String(options.sample)]),
95
+ // `--base` is emitted always rather than only when non-default: the default is `main`, and a
96
+ // rerun days later against a moved `main` is a different diff wearing the same flag.
97
+ ...(options.affected === undefined
98
+ ? []
99
+ : ['--affected', '--base', quoteArg(options.affected.base)]),
100
+ ...(options.affected?.dirty === true ? ['--dirty'] : []),
88
101
  '--workers',
89
102
  String(options.workers),
90
103
  '--worker',
@@ -107,6 +120,8 @@ export interface RunShardsOptions {
107
120
  * shard of the sample and would otherwise report that shard's size as the corpus.
108
121
  */
109
122
  readonly sample?: { readonly kept: number; readonly total: number };
123
+ /** Passed straight to `reproduceFor`: see `ReproduceOptions.affected`. */
124
+ readonly affected?: AffectedSelection;
110
125
  }
111
126
 
112
127
  /** The reproduction's inputs, resolved once: `workers` is the split's real width, not the ask. */
@@ -115,6 +130,7 @@ const planOf = (options: RunShardsOptions, workers: number): ReproduceOptions =>
115
130
  ...(options.filter === undefined ? {} : { filter: options.filter }),
116
131
  ...(options.type === undefined ? {} : { type: options.type }),
117
132
  ...(options.sample === undefined ? {} : { sample: options.sample.kept }),
133
+ ...(options.affected === undefined ? {} : { affected: options.affected }),
118
134
  });
119
135
 
120
136
  const failureOf = (shard: Shard, code: number, plan: ReproduceOptions): Finding => ({
@@ -35,6 +35,7 @@ import type { VerifyStep } from './verify-step';
35
35
  import { fromExec, fromFindings, hostFindings } from './verify-step';
36
36
  import { TEST_STEPS } from './verify-tests';
37
37
  import { checkFileSizes, checkPackageShape, hasWorkspacePackages } from './workspace-checks';
38
+ import { checkWorkspaceDependencies } from './workspace-graph';
38
39
 
39
40
  /** The one file that makes the `roadmap` step answerable, and therefore what `applies` reads. */
40
41
  const ROADMAP_FILE = join('docs', 'idea', '14-roadmap.md');
@@ -100,7 +101,16 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
100
101
  name: 'package-shape',
101
102
  summary: 'every package ships the same contract files',
102
103
  applies: (ctx) => hasWorkspacePackages(ctx.root),
103
- run: async (ctx) => fromFindings(await checkPackageShape(ctx.root)),
104
+ // The dependency rule rides here rather than becoming a twentieth step because it is this
105
+ // step's own question — what does a workspace owe the repo it lives in? — asked of the
106
+ // manifest's `dependencies` instead of its `files`. It is deliberately NOT inside
107
+ // `checkPackageShape`: `scripts/release.ts --check` calls that one to ask whether the tree is
108
+ // at the version a tag claims, and an undeclared import is not that question.
109
+ run: async (ctx) =>
110
+ fromFindings([
111
+ ...(await checkPackageShape(ctx.root)),
112
+ ...(await checkWorkspaceDependencies(ctx.root)),
113
+ ]),
104
114
  },
105
115
  {
106
116
  name: 'errors',