@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.
- package/CLAUDE.md +50 -4
- package/package.json +25 -24
- package/src/affected.ts +320 -0
- package/src/browser-launcher.ts +109 -0
- package/src/ci-log.ts +0 -0
- package/src/ci-runs.ts +179 -0
- package/src/cmd-affected.ts +109 -0
- package/src/cmd-build.ts +29 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/error-codes.ts +16 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +62 -3
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +9 -0
- package/src/messages.ts +64 -0
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/registry.ts +8 -0
- package/src/shot-verdict.ts +337 -0
- package/src/static-report.ts +219 -0
- package/src/templates/index.ts +1 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +129 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +20 -41
- package/src/templates/route.ts +14 -1
- package/src/templates/scaffold-app.ts +17 -3
- package/src/templates/scaffold-db-package.ts +32 -1
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +11 -1
- package/src/workspace-graph.ts +241 -0
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/templates/route.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
331
|
-
//
|
|
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,
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
54
|
-
*
|
|
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 => `{
|
package/src/test-shards.ts
CHANGED
|
@@ -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 `--
|
|
79
|
-
* shard, `--sample` decides how many of them survive, `--workers` decides the bins — drop
|
|
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 => ({
|
package/src/verify-checks.ts
CHANGED
|
@@ -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
|
-
|
|
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',
|