@ultimat3/cli 5.0.1 → 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 +75 -6
- package/README.md +2 -2
- package/package.json +28 -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 +36 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-dev.ts +35 -2
- package/src/cmd-generate.ts +16 -348
- package/src/cmd-i18n.ts +32 -16
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +10 -427
- package/src/compile-externals.ts +34 -0
- package/src/dev-lock.ts +275 -0
- package/src/dev-render.ts +7 -17
- package/src/error-codes.ts +18 -0
- package/src/generate-files.ts +127 -0
- package/src/generate-write.ts +229 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-audit.ts +39 -1
- package/src/i18n-registration.ts +130 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +68 -2
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +11 -0
- package/src/messages.ts +67 -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/solid-loader.ts +127 -0
- package/src/static-report.ts +219 -0
- package/src/templates/admin-page.ts +46 -5
- 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 +52 -43
- package/src/templates/route.ts +45 -6
- package/src/templates/scaffold-app.ts +70 -19
- package/src/templates/scaffold-container.ts +2 -2
- package/src/templates/scaffold-db-package.ts +88 -39
- package/src/templates/scaffold-docs.ts +18 -1
- package/src/templates/scaffold-i18n.ts +9 -2
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +2 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +349 -0
- package/src/verify-run.ts +122 -0
- package/src/verify-step.ts +7 -0
- package/src/workspace-graph.ts +241 -0
- package/types/babel-modules.d.ts +31 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Every finding about an app's strings, and the one composition `x i18n check` and `x verify`'s
|
|
2
|
+
// `i18n` step both report — two callers, one answer, so the command and the gate can never
|
|
3
|
+
// disagree. The runtime half is here because nothing else could ask it: `i18n-audit.ts` compares
|
|
4
|
+
// source against files on disk and was green for every string of a shipped app whose catalog
|
|
5
|
+
// module nothing imported (issue #249).
|
|
6
|
+
|
|
7
|
+
import type { Catalog, Extraction, ExtractReport, Locale } from '@ultimat3/i18n';
|
|
8
|
+
import {
|
|
9
|
+
auditCatalogs,
|
|
10
|
+
catalogFor,
|
|
11
|
+
catalogMissingKeys,
|
|
12
|
+
catalogRegistrationGaps,
|
|
13
|
+
catalogsNeverRegistered,
|
|
14
|
+
catalogUnregistered,
|
|
15
|
+
registeredLocales,
|
|
16
|
+
} from '@ultimat3/i18n';
|
|
17
|
+
import { loadApp } from './app-load';
|
|
18
|
+
import { auditApp } from './i18n-audit';
|
|
19
|
+
import type { Finding } from './output';
|
|
20
|
+
import { findingFrom } from './output';
|
|
21
|
+
import { catalogPath } from './templates/locales';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What this check needs of a boot. The seam is injected so a fixture can be exactly "the app
|
|
25
|
+
* loaded and registered nothing" — the shipped shape of the bug — without a temp directory that
|
|
26
|
+
* can resolve `@ultimat3/*`. `loadApp` is the production value, and it is the same call
|
|
27
|
+
* `serveApp` makes: asking a different loader than the server uses would prove nothing.
|
|
28
|
+
*/
|
|
29
|
+
export type AppLoader = (root: string) => Promise<{
|
|
30
|
+
readonly findings: readonly Finding[];
|
|
31
|
+
readonly defaultLocale: string;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
export interface RegistrationInput {
|
|
35
|
+
readonly root: string;
|
|
36
|
+
/** The catalogs on disk, parsed — `packages/i18n/catalogs/*.json`. */
|
|
37
|
+
readonly catalogs: Readonly<Record<Locale, Catalog>>;
|
|
38
|
+
readonly extraction: Extraction;
|
|
39
|
+
readonly ignoreUnused: readonly string[];
|
|
40
|
+
readonly load?: AppLoader;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RegistrationReport {
|
|
44
|
+
readonly ok: boolean;
|
|
45
|
+
readonly findings: readonly Finding[];
|
|
46
|
+
/** Keys that would render a loud miss because registration never happened. */
|
|
47
|
+
readonly unregistered: number;
|
|
48
|
+
/** How many locales are affected — one row of the summary's "across N locale(s)". */
|
|
49
|
+
readonly locales: number;
|
|
50
|
+
/** Which shipped locales the registry cannot fully answer — the `registered` column's `no`. */
|
|
51
|
+
readonly unregisteredLocales: readonly Locale[];
|
|
52
|
+
/** Every locale the registry holds after the load, sorted. Empty is not possible in a real
|
|
53
|
+
* boot: the framework's own catalog is the base layer, so `['en']` is the floor. */
|
|
54
|
+
readonly registered: readonly Locale[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The app ships nothing on disk, so there is no file to diff — the only evidence left is whether
|
|
59
|
+
* the keys source actually uses resolve. Audited through `auditCatalogs` rather than a fresh
|
|
60
|
+
* `hasOwn` loop, because a plural family is defined as `n_one`/`n_other` and a bare lookup of the
|
|
61
|
+
* stem `n` would report every plural in the app as unresolved.
|
|
62
|
+
*/
|
|
63
|
+
function unresolvedUsedKeys(input: RegistrationInput, locale: Locale): readonly string[] {
|
|
64
|
+
const report = auditCatalogs({
|
|
65
|
+
extraction: input.extraction,
|
|
66
|
+
catalogs: { [locale]: catalogFor(locale) },
|
|
67
|
+
ignoreUnused: input.ignoreUnused,
|
|
68
|
+
});
|
|
69
|
+
return report.locales[0]?.missing ?? [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function checkRegistration(input: RegistrationInput): Promise<RegistrationReport> {
|
|
73
|
+
// Importing the app's modules IS the registration, in this process exactly as in the server's.
|
|
74
|
+
const app = await (input.load ?? loadApp)(input.root);
|
|
75
|
+
|
|
76
|
+
const gaps = catalogRegistrationGaps(input.catalogs);
|
|
77
|
+
const findings: Finding[] = gaps.map((gap) => ({
|
|
78
|
+
...findingFrom(catalogUnregistered(gap)),
|
|
79
|
+
at: catalogPath(gap.locale),
|
|
80
|
+
}));
|
|
81
|
+
let unregistered = gaps.reduce((sum, gap) => sum + gap.missing.length, 0);
|
|
82
|
+
let locales = gaps.length;
|
|
83
|
+
|
|
84
|
+
if (Object.keys(input.catalogs).length === 0) {
|
|
85
|
+
const unresolved = unresolvedUsedKeys(input, app.defaultLocale);
|
|
86
|
+
if (unresolved.length > 0) {
|
|
87
|
+
findings.push(findingFrom(catalogsNeverRegistered(app.defaultLocale, unresolved)));
|
|
88
|
+
unregistered += unresolved.length;
|
|
89
|
+
locales += 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
ok: findings.length === 0,
|
|
95
|
+
// The load's own findings ride along ONLY when something is unregistered, and that condition is
|
|
96
|
+
// the whole value: a module that would not import registers nothing, so "packages/i18n/src/
|
|
97
|
+
// index.ts: SyntaxError" is the evidence for the gap above it. With every catalog registered, a
|
|
98
|
+
// broken route file is not this command's business and reporting it would be noise on a pass.
|
|
99
|
+
findings: findings.length === 0 ? findings : [...findings, ...app.findings],
|
|
100
|
+
unregistered,
|
|
101
|
+
locales,
|
|
102
|
+
unregisteredLocales: gaps.map((gap) => gap.locale),
|
|
103
|
+
registered: registeredLocales(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The file half: a key source uses that a locale's catalog does not define. Built here rather than
|
|
109
|
+
* in `cmd-i18n.ts` because `x verify`'s `i18n` step reports the same finding, and a second
|
|
110
|
+
* construction of it is two renderers of one fact waiting to drift.
|
|
111
|
+
*/
|
|
112
|
+
export function missingKeyFindings(report: ExtractReport): readonly Finding[] {
|
|
113
|
+
return report.locales
|
|
114
|
+
.filter((audit) => audit.missing.length > 0)
|
|
115
|
+
.map((audit) => ({
|
|
116
|
+
...findingFrom(catalogMissingKeys(audit.locale, audit.missing)),
|
|
117
|
+
at: catalogPath(audit.locale),
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Both halves of one question — does every string this app renders resolve? — for a caller that
|
|
123
|
+
* wants the verdict and not the table. `x verify`'s `i18n` step is that caller; `cmd-i18n.ts`
|
|
124
|
+
* composes the same two pieces itself because it also renders per-locale rows.
|
|
125
|
+
*/
|
|
126
|
+
export async function catalogFindings(root: string): Promise<readonly Finding[]> {
|
|
127
|
+
const { report, catalogs, extraction, ignoreUnused } = await auditApp(root);
|
|
128
|
+
const registration = await checkRegistration({ root, catalogs, extraction, ignoreUnused });
|
|
129
|
+
return [...missingKeyFindings(report), ...registration.findings];
|
|
130
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -60,6 +60,12 @@ export {
|
|
|
60
60
|
plannedCommands,
|
|
61
61
|
plannedSubcommand,
|
|
62
62
|
} from './cmd-planned';
|
|
63
|
+
// `shotCommand`, `prCommand` and `ciCommand` are deliberately NOT re-exported here. They reach
|
|
64
|
+
// `x` through `registry.ts`, which is the only thing that makes a command exist — and the barrel
|
|
65
|
+
// is the surface an APP imports. Exporting them puts `cmd-shot.ts` in the module graph of every
|
|
66
|
+
// app that imports `@ultimat3/cli`, which then has to resolve `@ultimat3/scraping` — a browser
|
|
67
|
+
// driver it never uses. Measured: it reds `tsc -b` on `dummy/social-media-clone` with five
|
|
68
|
+
// TS2307s in files that app never calls. The app path does not pay for the tool path.
|
|
63
69
|
export { actionsCommand, entitiesCommand, queriesCommand } from './cmd-registries';
|
|
64
70
|
export { renderRouteTable, routesCommand } from './cmd-routes';
|
|
65
71
|
export { testCommand } from './cmd-test';
|
|
@@ -177,6 +183,13 @@ export type { DeclaredFlag } from './flag-reads';
|
|
|
177
183
|
export { checkFlagReads, declaredFlags, readsFlag } from './flag-reads';
|
|
178
184
|
export type { Guard } from './guards';
|
|
179
185
|
export { findingProblem, GUARD_DIR, guardFindings, guardPaths } from './guards';
|
|
186
|
+
// The island bundler, and only its entry point. An island is the one module Ultimate ships to a
|
|
187
|
+
// browser, so an app has to be able to build one to TEST one — `mountIsland` from
|
|
188
|
+
// `@ultimat3/testing` takes this function as its `build` parameter (issue #260). `discoverIslands`,
|
|
189
|
+
// `islandBundle`, `writeIslands`, `ISLAND_BASE_PATH` and `ISLAND_GLOB` stay internal: they are
|
|
190
|
+
// `x build`'s and `x dev`'s wiring, and every name here is a semver promise forever.
|
|
191
|
+
export type { IslandBundle, IslandChunk } from './island-bundle';
|
|
192
|
+
export { buildIslands } from './island-bundle';
|
|
180
193
|
export type { DrainFailure, DrainOutcome, DrainSkip } from './jobs-drain';
|
|
181
194
|
export { drainJobs } from './jobs-drain';
|
|
182
195
|
export type { JobsListFilter, JobsListResult } from './jobs-report';
|
|
@@ -235,6 +248,24 @@ export {
|
|
|
235
248
|
isVendored,
|
|
236
249
|
SOURCE_GLOBS,
|
|
237
250
|
} from './source-files';
|
|
251
|
+
export type {
|
|
252
|
+
EmittedPage,
|
|
253
|
+
RouteFacts,
|
|
254
|
+
SkippedRoute,
|
|
255
|
+
SkipReason,
|
|
256
|
+
StaticReport,
|
|
257
|
+
} from './static-report';
|
|
258
|
+
export {
|
|
259
|
+
parseStaticReport,
|
|
260
|
+
readStaticReport,
|
|
261
|
+
removeStaticReport,
|
|
262
|
+
renderStaticReport,
|
|
263
|
+
SKIP_REASONS,
|
|
264
|
+
STATIC_REPORT_FILE,
|
|
265
|
+
skippedRoute,
|
|
266
|
+
skipReasonFor,
|
|
267
|
+
writeStaticReport,
|
|
268
|
+
} from './static-report';
|
|
238
269
|
export type { TestCounts } from './test-counts';
|
|
239
270
|
export { countsOf } from './test-counts';
|
|
240
271
|
export type { TestFile } from './test-select';
|
|
@@ -286,4 +317,10 @@ export {
|
|
|
286
317
|
SEMVER,
|
|
287
318
|
workspacePackages,
|
|
288
319
|
} from './workspace-checks';
|
|
320
|
+
export type { WorkspaceNode, WorkspaceScan } from './workspace-graph';
|
|
321
|
+
// The graph itself, not just the gate's verdict on it: issue #239's complaint is that a
|
|
322
|
+
// scaffolded repo's dependency graph exists only inside `tsc`, so an app's own tooling has
|
|
323
|
+
// nothing to read. `checkWorkspaceDependencies` stays internal — it is reached through
|
|
324
|
+
// `x verify`, which is the one way a rule is enforced here.
|
|
325
|
+
export { readWorkspaceGraph, scanWorkspaces } from './workspace-graph';
|
|
289
326
|
export { writeLine } from './write-line';
|
package/src/island-bundle.ts
CHANGED
|
@@ -13,6 +13,9 @@ import {
|
|
|
13
13
|
islandModuleId,
|
|
14
14
|
} from '@ultimat3/render';
|
|
15
15
|
import { IslandBuildFailedError } from './errors';
|
|
16
|
+
import { solidProductionPlugin } from './island-solid-production';
|
|
17
|
+
import { islandStylesPlugin } from './island-styles';
|
|
18
|
+
import { solidJsxPlugin } from './solid-loader';
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* Where a chunk is served from, in `x dev`, in the container and in a static export — one base
|
|
@@ -78,6 +81,18 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
|
|
|
78
81
|
format: 'esm',
|
|
79
82
|
splitting: false,
|
|
80
83
|
minify: true,
|
|
84
|
+
// A build with no `plugins` is a build with no JSX transform: `Bun.plugin` installs into the
|
|
85
|
+
// RUNTIME's loader and `Bun.build` walks its own graph, so render's `.tsx` loader never sees
|
|
86
|
+
// an island. The app's tsconfig says `jsx: "preserve"`, which makes the bundler fall back to
|
|
87
|
+
// classic `React.createElement` — emitted into a browser chunk that imports no React, with
|
|
88
|
+
// `success: true` and no log. Every island shipped that way through five majors.
|
|
89
|
+
//
|
|
90
|
+
// The other two close the same shape of failure — a wrong answer `Bun.build` reports as
|
|
91
|
+
// `success: true`: without the second, `target: 'browser'` resolves the `development`
|
|
92
|
+
// export condition and the chunk carries Solid's dev build; without the third, Bun's file
|
|
93
|
+
// loader resolves a `.module.scss` to its asset PATH, so `styles['x']` is `undefined` and
|
|
94
|
+
// every element renders unclassed.
|
|
95
|
+
plugins: [solidJsxPlugin, solidProductionPlugin, islandStylesPlugin],
|
|
81
96
|
});
|
|
82
97
|
} catch (error) {
|
|
83
98
|
throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
|
|
@@ -114,13 +129,64 @@ function describeBuildError(error: unknown): string {
|
|
|
114
129
|
return error instanceof Error ? error.message : String(error);
|
|
115
130
|
}
|
|
116
131
|
|
|
132
|
+
export interface BuildIslandsOptions {
|
|
133
|
+
/**
|
|
134
|
+
* Build ONE island, named app-root-relative — the whole option surface. A test that mounts a
|
|
135
|
+
* single island otherwise pays every OTHER island's Babel pass and `Bun.build` on every file,
|
|
136
|
+
* and the reference app is the one that feels it.
|
|
137
|
+
*
|
|
138
|
+
* Optional, and it must stay optional: `buildIslands` is on `@ultimat3/cli`'s public surface and
|
|
139
|
+
* `@ultimat3/testing`'s `IslandBuilder` satisfies it STRUCTURALLY as `(root: string) => …`, which
|
|
140
|
+
* is what keeps the `cli -> testing` edge pointing the one legal way.
|
|
141
|
+
*/
|
|
142
|
+
readonly only?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
117
145
|
/** Build every island in the app. An app with none returns an empty bundle and costs one glob. */
|
|
118
|
-
export async function buildIslands(
|
|
119
|
-
|
|
146
|
+
export async function buildIslands(
|
|
147
|
+
root: string,
|
|
148
|
+
options: BuildIslandsOptions = {},
|
|
149
|
+
): Promise<IslandBundle> {
|
|
150
|
+
const discovered = await discoverIslands(root);
|
|
151
|
+
const only = options.only;
|
|
152
|
+
const files = only === undefined ? discovered : discovered.filter((file) => file === only);
|
|
153
|
+
// A filter that matches nothing is a typo in the CALLER, never an app with no islands. Answering
|
|
154
|
+
// an empty bundle here would surface two steps later, as a chunk table with no entry for a file
|
|
155
|
+
// the caller can see on disk.
|
|
156
|
+
if (only !== undefined && files.length === 0) throw onlyMissing(only, discovered);
|
|
120
157
|
const chunks = await Promise.all(files.map((file) => buildOne(root, file)));
|
|
121
158
|
return islandBundle(chunks);
|
|
122
159
|
}
|
|
123
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Same code as an unbuildable `src`: "this path cannot become a client entry" is one condition.
|
|
163
|
+
*
|
|
164
|
+
* Two fixes, because there are two causes and only one of them can be repaired by naming a path.
|
|
165
|
+
* The line was `pass only: '<app-root-relative path>.island.tsx'` — a placeholder nobody can run,
|
|
166
|
+
* which no gate could see: `fixProblem` fails a fix only for ADVICE with no command token, and a
|
|
167
|
+
* sentence with neither is not advice. Both forms below are constructed from what the caller
|
|
168
|
+
* already handed in, so neither can name a path this app does not have.
|
|
169
|
+
*/
|
|
170
|
+
function onlyMissing(only: string, discovered: readonly string[]): IslandInvalidError {
|
|
171
|
+
const cause =
|
|
172
|
+
`buildIslands was asked for ${JSON.stringify(only)} alone, which is not one of the ` +
|
|
173
|
+
`${discovered.length} islands this app has (${discovered.length === 0 ? 'none' : discovered.join(', ')})`;
|
|
174
|
+
// The basename match first: a filter that misses normally missed on the PREFIX — a route-relative
|
|
175
|
+
// specifier where `discoverIslands`' app-root-relative path was wanted — and the filename
|
|
176
|
+
// survives that. Falling back to the first keeps the fix a real path rather than a shape.
|
|
177
|
+
const nearest =
|
|
178
|
+
discovered.find((file) => posix.basename(file) === posix.basename(only)) ?? discovered[0];
|
|
179
|
+
// An app with no islands cannot be pointed at one, so the fix WRITES the file that was asked
|
|
180
|
+
// for — the same command `entryMissing` hands back, split off the same path.
|
|
181
|
+
if (nearest === undefined) {
|
|
182
|
+
return new IslandInvalidError(
|
|
183
|
+
cause,
|
|
184
|
+
`x g island ${posix.basename(only, ISLAND_EXTENSION)} --at ${posix.dirname(only)}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return new IslandInvalidError(cause, `buildIslands(root, { only: '${nearest}' })`);
|
|
188
|
+
}
|
|
189
|
+
|
|
124
190
|
export function islandBundle(chunks: readonly IslandChunk[]): IslandBundle {
|
|
125
191
|
const byFile = new Map(chunks.map((chunk) => [chunk.file, chunk]));
|
|
126
192
|
const byUrl = new Map(chunks.map((chunk) => [chunk.url, chunk]));
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Every `solid-js` import in an island chunk resolves to Solid's PRODUCTION browser build.
|
|
2
|
+
// `Bun.build({ target: 'browser' })` always adds the `development` export condition and offers no
|
|
3
|
+
// option that removes it — `conditions`, `production`, `env` and `define` were each measured under
|
|
4
|
+
// Bun 1.4 and none of them does — so without this seam an island ships the dev bundle silently.
|
|
5
|
+
|
|
6
|
+
// `node:path` by necessity: Bun ships no path API, and this file resolves a package entry back
|
|
7
|
+
// to the directory its `exports` map is relative to.
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import type { BunPlugin } from 'bun';
|
|
10
|
+
import { IslandBuildFailedError } from './errors';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The conditions an island's `solid-js` subpath is resolved under. `development` is the one NOT in
|
|
14
|
+
* the set, which is the whole point of the file; `production` is in it because an island chunk is
|
|
15
|
+
* only ever built to be shipped — `x dev` serves the same chunk the container does, so a second
|
|
16
|
+
* answer here would be a bundle the byte budget never measured.
|
|
17
|
+
*/
|
|
18
|
+
const ISLAND_CONDITIONS: ReadonlySet<string> = new Set([
|
|
19
|
+
'production',
|
|
20
|
+
'browser',
|
|
21
|
+
'module',
|
|
22
|
+
'import',
|
|
23
|
+
'default',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** `solid-js` and its subpaths, and nothing else: Solid is the runtime an island is compiled for. */
|
|
27
|
+
const SOLID_SPECIFIER = /^solid-js(?:\/|$)/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Node's conditional-exports walk, restricted to what this file needs: the first key of the object
|
|
31
|
+
* that the build's condition set contains, depth-first, with an array as an ordered fallback list.
|
|
32
|
+
* Written out rather than delegated to `Bun.resolveSync` because the ONE thing it has to do
|
|
33
|
+
* differently from Bun's resolver is refuse `development` — and `types` with it, which would
|
|
34
|
+
* otherwise win on Solid's map and hand the bundler a `.d.ts`.
|
|
35
|
+
*/
|
|
36
|
+
export function selectCondition(node: unknown, conditions: ReadonlySet<string>): string | null {
|
|
37
|
+
if (typeof node === 'string') return node;
|
|
38
|
+
if (Array.isArray(node)) {
|
|
39
|
+
for (const alternative of node as readonly unknown[]) {
|
|
40
|
+
const picked = selectCondition(alternative, conditions);
|
|
41
|
+
if (picked !== null) return picked;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (typeof node !== 'object' || node === null) return null;
|
|
46
|
+
for (const [condition, value] of Object.entries(node)) {
|
|
47
|
+
if (!conditions.has(condition)) continue;
|
|
48
|
+
const picked = selectCondition(value, conditions);
|
|
49
|
+
if (picked !== null) return picked;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One parse per manifest: an island imports Solid from several files, and every file asks again. */
|
|
55
|
+
const exportsCache = new Map<string, unknown>();
|
|
56
|
+
|
|
57
|
+
async function exportsOf(manifest: string): Promise<unknown> {
|
|
58
|
+
const hit = exportsCache.get(manifest);
|
|
59
|
+
if (hit !== undefined || exportsCache.has(manifest)) return hit;
|
|
60
|
+
const parsed: unknown = JSON.parse(await Bun.file(manifest).text());
|
|
61
|
+
const field =
|
|
62
|
+
typeof parsed === 'object' && parsed !== null && 'exports' in parsed
|
|
63
|
+
? (parsed as { readonly exports?: unknown }).exports
|
|
64
|
+
: undefined;
|
|
65
|
+
exportsCache.set(manifest, field);
|
|
66
|
+
return field;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Test seam: the cache is process-global because `x dev` rebuilds in one process. */
|
|
70
|
+
export function clearSolidExportsCache(): void {
|
|
71
|
+
exportsCache.clear();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The absolute file `specifier` must resolve to, or `null` for "Bun's own answer is already the
|
|
76
|
+
* right one" — which is every subpath Solid declares as a plain string or a pattern, since a
|
|
77
|
+
* declaration with no conditions on it cannot select the development build.
|
|
78
|
+
*/
|
|
79
|
+
export async function solidProductionEntry(
|
|
80
|
+
specifier: string,
|
|
81
|
+
resolveDir: string,
|
|
82
|
+
importer: string,
|
|
83
|
+
): Promise<string | null> {
|
|
84
|
+
let manifest: string;
|
|
85
|
+
try {
|
|
86
|
+
// `solid-js/package.json` is an `exports` entry of Solid's own map, so this reaches the exact
|
|
87
|
+
// copy the island would have imported — not a hoisted sibling at a different version.
|
|
88
|
+
manifest = Bun.resolveSync('solid-js/package.json', resolveDir);
|
|
89
|
+
} catch {
|
|
90
|
+
// Solid is not installed here. Bun's resolver says so, in its own words, naming the importer.
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const field = await exportsOf(manifest);
|
|
94
|
+
const subpath = specifier === 'solid-js' ? '.' : `.${specifier.slice('solid-js'.length)}`;
|
|
95
|
+
if (typeof field !== 'object' || field === null || !(subpath in field)) return null;
|
|
96
|
+
|
|
97
|
+
const entry = selectCondition((field as Record<string, unknown>)[subpath], ISLAND_CONDITIONS);
|
|
98
|
+
const file = entry === null ? null : join(dirname(manifest), entry);
|
|
99
|
+
if (file === null || !(await Bun.file(file).exists())) {
|
|
100
|
+
throw new IslandBuildFailedError({
|
|
101
|
+
file: importer.length > 0 ? importer : specifier,
|
|
102
|
+
logs:
|
|
103
|
+
`${specifier} has no production browser entry: ${manifest} answers ` +
|
|
104
|
+
`${entry === null ? 'nothing' : JSON.stringify(entry)} under ` +
|
|
105
|
+
`[${[...ISLAND_CONDITIONS].join(', ')}], and an island may not ship Solid's ` +
|
|
106
|
+
'development build',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return file;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The plugin `island-bundle.ts` hands `Bun.build`, beside `solidJsxPlugin`. Stateless apart from
|
|
114
|
+
* the manifest cache, so one frozen descriptor serves every concurrent island build.
|
|
115
|
+
*
|
|
116
|
+
* The absolute path it answers with no longer matches `SOLID_SPECIFIER`, so Solid's own internal
|
|
117
|
+
* `import … from 'solid-js'` is the only re-entry — and that one is wanted: it is how `web.js`
|
|
118
|
+
* reaches `solid.js` rather than `dev.js`.
|
|
119
|
+
*/
|
|
120
|
+
export const solidProductionPlugin: BunPlugin = {
|
|
121
|
+
name: 'ultimate-island-solid-production',
|
|
122
|
+
setup(build): void {
|
|
123
|
+
build.onResolve({ filter: SOLID_SPECIFIER }, async ({ path, importer, resolveDir }) => {
|
|
124
|
+
const from = resolveDir.length > 0 ? resolveDir : dirname(importer);
|
|
125
|
+
const entry = await solidProductionEntry(path, from, importer);
|
|
126
|
+
return entry === null ? undefined : { path: entry };
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The stylesheet half of an island build: `import styles from './x.module.scss'` answers the class
|
|
2
|
+
// map the SERVER hashed, and the CSS lands in the same registry a document renders from. Bun's
|
|
3
|
+
// default loader answers the asset PATH — a string — so `styles['track']` is `undefined`, every
|
|
4
|
+
// element renders unclassed, and `Bun.build` reports `success: true` with no log.
|
|
5
|
+
|
|
6
|
+
import { renderThrowable, UltimateError } from '@ultimat3/core';
|
|
7
|
+
import { loadStylesheet } from '@ultimat3/render';
|
|
8
|
+
import type { BunPlugin } from 'bun';
|
|
9
|
+
import { IslandBuildFailedError } from './errors';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The same spelling `installRenderLoader` filters on, so there is ONE answer to "what is a
|
|
13
|
+
* stylesheet import" across the server loader and the island bundler. A plain `.css`/`.scss` is in
|
|
14
|
+
* deliberately: it compiles to an empty class map and registers its rules, which is what a global
|
|
15
|
+
* stylesheet an island imports has to do.
|
|
16
|
+
*/
|
|
17
|
+
const STYLESHEET = /\.s?css$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `loadStylesheet`, not `compileStylesheet`: compiling alone answers the class names and drops the
|
|
21
|
+
* RULES on the floor, and an island is the one importer a document's own module graph never sees.
|
|
22
|
+
* Registering here is what puts them in `stylesFor(surface)` — `buildIslands` runs before the first
|
|
23
|
+
* document is rendered, in `x dev` and in `prerenderSite` alike.
|
|
24
|
+
*/
|
|
25
|
+
export const islandStylesPlugin: BunPlugin = {
|
|
26
|
+
name: 'ultimate-island-styles',
|
|
27
|
+
setup(build): void {
|
|
28
|
+
build.onLoad({ filter: STYLESHEET }, async ({ path }) => {
|
|
29
|
+
const source = await Bun.file(path).text();
|
|
30
|
+
try {
|
|
31
|
+
return { contents: loadStylesheet(path, source), loader: 'js' };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// A coded failure already names the file and carries a fix — re-wrapping it would bury
|
|
34
|
+
// both. Anything else is rendered through `renderThrowable`, because this is the plugin's
|
|
35
|
+
// last frame and a value that fights being read would escape a Bun plugin as a bare throw.
|
|
36
|
+
if (error instanceof UltimateError) throw error;
|
|
37
|
+
throw new IslandBuildFailedError({ file: path, logs: renderThrowable(error) });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
package/src/mcp-errors.ts
CHANGED
|
@@ -48,6 +48,15 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
48
48
|
X_JOB_UNKNOWN: 'x jobs ls --json',
|
|
49
49
|
X_FIX_TARGET_UNKNOWN: 'x fix boundary apps/web/site/page.tsx --json',
|
|
50
50
|
X_ERROR_FIX_INVALID: 'x verify --json # the finding names the file, the line and the fix text',
|
|
51
|
+
X_WORKSPACE_DEP_UNDECLARED:
|
|
52
|
+
'x verify --json # the package-shape finding carries the dependency line to add',
|
|
53
|
+
X_SHOT_BROWSER_MISSING: 'bun add -d puppeteer-core',
|
|
54
|
+
X_GH_UNAVAILABLE: 'gh auth login # install first from https://cli.github.com',
|
|
55
|
+
X_GH_NOT_AUTHENTICATED: 'gh auth login',
|
|
56
|
+
X_GH_COMMAND_FAILED: 'x ci --json # the finding carries the gh invocation that failed',
|
|
57
|
+
X_GH_RESPONSE_INVALID: 'x pr review --json # the finding names the field that did not parse',
|
|
58
|
+
X_PR_NOT_FOUND: 'x pr review --pr 1 --json # or open one first with: gh pr create',
|
|
59
|
+
X_CI_RUN_NOT_FOUND: 'x ci --branch main --json',
|
|
51
60
|
X_ERROR_CODE_UNDOCUMENTED: 'x verify --json # the finding names the code and the missing page',
|
|
52
61
|
X_ERROR_CODE_UNREGISTERED:
|
|
53
62
|
'x errors list --json # register the code in its package src/errors.ts, or move its row under "Reserved codes"',
|
|
@@ -91,6 +100,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
91
100
|
X_RUNTIME_DRIVER_SPLIT: 'x dev --json # the boot names the driver the app installed twice',
|
|
92
101
|
X_GENERATE_CONFLICT: 'x g route posts --force --json',
|
|
93
102
|
X_PORT_IN_USE: 'x dev --port 3001 --json',
|
|
103
|
+
X_DEV_ALREADY_RUNNING:
|
|
104
|
+
'x dev --json # after stopping the x dev that already owns this checkout',
|
|
94
105
|
// Not `x db status`: there is no such subcommand (`x db` is gen, migrate, reset, studio, branch),
|
|
95
106
|
// so the fix answered a failed step with X_CLI_UNKNOWN_COMMAND. `x doctor` is what reports
|
|
96
107
|
// reachability and drift, and is already this table's answer for X_DB_STUDIO_FAILED.
|
package/src/messages.ts
CHANGED
|
@@ -72,6 +72,9 @@ const CATALOG = {
|
|
|
72
72
|
'cli.dev.mail.external': 'mail=external({driver} via {detail})',
|
|
73
73
|
'cli.dev.mail.refused': 'mail=refused({detail})',
|
|
74
74
|
'cli.dev.hmr': 'reloaded {file} in {ms}ms',
|
|
75
|
+
// A hard kill leaves the lock behind and that is normal, not a fault — worth one line so a
|
|
76
|
+
// reader knows why the boot paused, and never a finding.
|
|
77
|
+
'cli.dev.staleLock': 'cleared a stale dev.lock — the previous x dev did not shut down cleanly',
|
|
75
78
|
'cli.dev.roles': ' roles {roles}',
|
|
76
79
|
'cli.dev.panels': ' panels {panels}',
|
|
77
80
|
'cli.dev.introspect': ' introspect {url}',
|
|
@@ -156,7 +159,71 @@ const CATALOG = {
|
|
|
156
159
|
'cli.routes.empty': 'no routes in the manifest — run `x manifest` first',
|
|
157
160
|
'cli.tasks.count': '{count} task(s)',
|
|
158
161
|
'cli.tasks.shown': '{name} — {cron} ({tz}), next {next}',
|
|
162
|
+
'cli.affected.count':
|
|
163
|
+
'{count} workspace(s) affected by {base}...HEAD, from {changed} changed file(s)',
|
|
164
|
+
'cli.affected.dirty':
|
|
165
|
+
' including the working tree (--dirty): every uncommitted change in this checkout, whoever made it',
|
|
166
|
+
'cli.affected.none': 'no workspace is affected by {base}...HEAD, from {changed} changed file(s)',
|
|
167
|
+
'cli.affected.rootWide':
|
|
168
|
+
' every workspace: {files} belongs to none of them and changes what all of them compile',
|
|
169
|
+
// `x shot` — the picture is the `lines`, the verdict is the artifact. The summary names the
|
|
170
|
+
// GATING fact, and a redirect comes first: a photograph of the sign-in page with every island
|
|
171
|
+
// missing reads as a bug in the app, and it is a bug in the capture.
|
|
172
|
+
'cli.shot.ok': '{route} clean — {islands} island(s) mounted, nothing logged and nothing threw',
|
|
173
|
+
'cli.shot.errors': '{route}: {errors} console error(s) — verdict.json names each one',
|
|
174
|
+
'cli.shot.redirected': '{route} redirected to {url} — the picture is not the route asked for',
|
|
175
|
+
'cli.shot.server.booted': ' server booted for this shot on {url}',
|
|
176
|
+
'cli.shot.server.reused': ' server the x dev already running on {url}',
|
|
177
|
+
'cli.shot.canvas': ' canvas {width}x{height}',
|
|
178
|
+
'cli.shot.canvasUnreadable': ' canvas unreadable — {bytes} byte(s), not a decodable image',
|
|
179
|
+
'cli.shot.islands': ' islands {booted} of {declared} mounted ({strategies})',
|
|
180
|
+
'cli.shot.islandsUnknown': ' islands not counted — the page answered no probe',
|
|
181
|
+
'cli.shot.network': ' network {requests} request(s), {refused} refused, {dropped} dropped',
|
|
182
|
+
'cli.shot.console': ' console {level}: {text}',
|
|
183
|
+
'cli.shot.threw': '{route}: {thrown} uncaught exception(s) — {first}',
|
|
184
|
+
'cli.shot.pageError': ' threw {message} {at}',
|
|
185
|
+
'cli.shot.picture': ' picture {path}',
|
|
186
|
+
'cli.shot.verdict': ' verdict {path}',
|
|
187
|
+
'cli.shot.blind.status':
|
|
188
|
+
'HTTP response status is not observed — the port records requests, never responses',
|
|
189
|
+
'cli.ci.failed':
|
|
190
|
+
'{failed} of {runs} workflow run(s) on {branch} failed — {findings} finding(s) recovered from the log',
|
|
191
|
+
'cli.ci.green': 'every one of {runs} workflow run(s) on {branch} passed',
|
|
192
|
+
'cli.ci.job': ' {conclusion} {job} ({steps})',
|
|
193
|
+
'cli.ci.jobs.other': ' {count} other job(s) in this run',
|
|
194
|
+
'cli.ci.logs.empty': ' the failed step wrote no log — {url}',
|
|
195
|
+
/** The conclusion of a run GitHub has not finished — a value, not a column key. */
|
|
196
|
+
'cli.ci.pending': 'pending',
|
|
197
|
+
'cli.ci.run': '{conclusion} {workflow} {url}',
|
|
198
|
+
'cli.ci.running':
|
|
199
|
+
'{running} of {runs} workflow run(s) on {branch} has not finished — nothing has failed yet',
|
|
200
|
+
'cli.ci.tail': ' log tail, {job}:',
|
|
201
|
+
'cli.pr.body.truncated': ' … {hidden} more line(s) — re-run with --full',
|
|
202
|
+
/** The line of a thread whose anchor GitHub answers null for — a value, not a column key. */
|
|
203
|
+
'cli.pr.line.unknown': '-',
|
|
204
|
+
'cli.pr.replied': 'replied on thread {id}: {url}',
|
|
205
|
+
// Resolving closes a CONVERSATION. Whether the finding is fixed is a fact about the code that
|
|
206
|
+
// no GitHub mutation observes, and a summary saying "addressed" would assert one from the other.
|
|
207
|
+
'cli.pr.resolved':
|
|
208
|
+
'thread {id} is marked resolved on GitHub — that records the conversation, not that the finding is fixed',
|
|
209
|
+
'cli.pr.review.count':
|
|
210
|
+
'{unresolved} unresolved and {resolved} resolved review thread(s) on {repo}#{pr}',
|
|
211
|
+
'cli.pr.review.current': ' submitted against the current head {head}',
|
|
212
|
+
'cli.pr.review.decision': ' review: {decision} by {author} at {submitted}',
|
|
213
|
+
'cli.pr.review.none': 'no review thread is anchored to a line on {repo}#{pr}',
|
|
214
|
+
'cli.pr.review.stale':
|
|
215
|
+
' submitted against {commit}; the head is now {head} ({committed}) — this decision predates the current code',
|
|
216
|
+
'cli.pr.review.truncated':
|
|
217
|
+
' more than {count} threads — this is the first page, not the whole review',
|
|
218
|
+
'cli.pr.review.undecided': ' GitHub reports no review decision yet',
|
|
219
|
+
'cli.pr.thread.closed': ' resolved {path}:{line} {id}',
|
|
220
|
+
'cli.pr.thread.comment': ' {author} at {createdAt}',
|
|
221
|
+
'cli.pr.thread.more': ' {hidden} more comment(s) on this thread',
|
|
222
|
+
'cli.pr.thread.open': ' unresolved {path}:{line} {id}',
|
|
223
|
+
'cli.pr.thread.outdated':
|
|
224
|
+
' the diff has moved under this thread — {line} is where the comment was written',
|
|
159
225
|
'cli.test.fail': '{failed} of {workers} shard(s) failed',
|
|
226
|
+
'cli.test.affected.none': 'nothing is affected by {base}...HEAD — 0 test file(s) ran',
|
|
160
227
|
'cli.test.pass': '{files} test file(s) on {workers} worker(s) passed in {ms}ms',
|
|
161
228
|
'cli.test.sampled': 'sampled {kept} of {total} {type} file(s)',
|
|
162
229
|
'cli.test.type.fail': '{type} — {failed} of {workers} shard(s) failed',
|