@ultimat3/cli 6.0.0 → 8.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 +65 -5
- package/README.md +8 -3
- package/package.json +25 -24
- package/src/affected.ts +320 -0
- package/src/app-boundaries.ts +55 -5
- package/src/bin.ts +6 -3
- 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-db-backfill.ts +240 -0
- package/src/cmd-db-branch.ts +3 -2
- package/src/cmd-db.ts +35 -156
- package/src/cmd-deploy.ts +37 -3
- package/src/cmd-dev.ts +7 -1
- package/src/cmd-errors.ts +2 -3
- package/src/cmd-fix.ts +3 -3
- package/src/cmd-i18n.ts +67 -5
- package/src/cmd-jobs.ts +27 -4
- package/src/cmd-mcp.ts +18 -9
- package/src/cmd-new.ts +91 -4
- package/src/cmd-policy.ts +3 -2
- package/src/cmd-pr.ts +359 -0
- package/src/cmd-registries.ts +3 -2
- package/src/cmd-shot.ts +382 -0
- package/src/cmd-tasks.ts +9 -4
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +47 -6
- package/src/dev-cache.ts +1 -1
- package/src/dev-lock.ts +124 -12
- package/src/dev-queue.ts +12 -7
- package/src/dev-replicator.ts +3 -7
- package/src/dev-roles-fixture.ts +1 -1
- package/src/dev-roles.ts +40 -8
- package/src/dev-runtime.ts +96 -4
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- package/src/drift.ts +52 -7
- package/src/error-codes.ts +21 -0
- package/src/framework-scope.ts +57 -5
- package/src/generate-kinds.ts +19 -1
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-registration.ts +67 -4
- package/src/index.ts +38 -1
- package/src/island-bundle.ts +62 -3
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/jobs-report.ts +10 -13
- package/src/mcp-errors.ts +12 -0
- package/src/messages.ts +76 -0
- package/src/output.ts +22 -2
- package/src/parse.ts +81 -37
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/realtime-browser-probe-fixture.ts +9 -0
- package/src/registry.ts +8 -0
- package/src/runtime-overrides.ts +11 -3
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +360 -0
- package/src/static-report.ts +219 -0
- package/src/sync-authenticator.ts +86 -14
- package/src/templates/guard-bare-error.ts +122 -0
- package/src/templates/guard-raw-colour.ts +138 -0
- package/src/templates/guard-untranslated-string.ts +138 -0
- package/src/templates/guard-unzoned-date.ts +142 -0
- package/src/templates/index.ts +4 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +130 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +20 -41
- package/src/templates/route.ts +15 -2
- package/src/templates/scaffold-app.ts +13 -78
- package/src/templates/scaffold-container.ts +30 -4
- package/src/templates/scaffold-db-package.ts +46 -7
- package/src/templates/scaffold-docs.ts +24 -13
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +37 -6
- package/src/test-select.ts +4 -3
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +11 -1
- package/src/verify-run.ts +25 -3
- package/src/verify-step.ts +11 -2
- package/src/verify-tests.ts +11 -3
- package/src/workspace-graph.ts +241 -0
- package/src/write-line.ts +23 -5
package/src/i18n-registration.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
catalogRegistrationGaps,
|
|
13
13
|
catalogsNeverRegistered,
|
|
14
14
|
catalogUnregistered,
|
|
15
|
+
pluralVariantsOf,
|
|
15
16
|
registeredLocales,
|
|
16
17
|
} from '@ultimat3/i18n';
|
|
17
18
|
import { loadApp } from './app-load';
|
|
@@ -105,9 +106,68 @@ export async function checkRegistration(input: RegistrationInput): Promise<Regis
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
109
|
+
* `⟦key⟧` — `@ultimat3/i18n`'s own loud miss, spelled ONCE for the whole CLI. `x i18n sync <default>`
|
|
110
|
+
* writes it and the two checks below refuse it, so a second spelling would be a placeholder one
|
|
111
|
+
* half of this package writes and the other half cannot see.
|
|
112
|
+
*/
|
|
113
|
+
const MISS_OPEN = '\u27E6';
|
|
114
|
+
const MISS_CLOSE = '\u27E7';
|
|
115
|
+
|
|
116
|
+
/** What `x i18n sync` seeds a key with when there is no catalog above it to copy a value from. */
|
|
117
|
+
export const loudMiss = (key: string): string => `${MISS_OPEN}${key}${MISS_CLOSE}`;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A value that is a placeholder rather than a translation. Any `⟦…⟧`, not just `⟦<this key>⟧`:
|
|
121
|
+
* an author who renames a key and leaves the old marker behind still ships a placeholder.
|
|
122
|
+
*/
|
|
123
|
+
export const isLoudMiss = (value: string | undefined): boolean =>
|
|
124
|
+
value?.startsWith(MISS_OPEN) === true && value.endsWith(MISS_CLOSE);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether `locale` answers `key` with a placeholder. Every spelling `definesKey` accepts is
|
|
128
|
+
* checked — `pluralVariantsOf` is `@ultimat3/i18n`'s own list, the same one `auditCatalogs` uses,
|
|
129
|
+
* so "defined" and "defined with a real string" can never disagree about which entries count.
|
|
130
|
+
* `some`, not `every`: a plural family with one untranslated category renders `⟦items_many⟧` on
|
|
131
|
+
* exactly the rows that hit it.
|
|
132
|
+
*/
|
|
133
|
+
const answersWithPlaceholder = (catalog: Catalog, key: string): boolean =>
|
|
134
|
+
[key, ...pluralVariantsOf(key)].some(
|
|
135
|
+
(candidate) => Object.hasOwn(catalog, candidate) && isLoudMiss(catalog[candidate]),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The audit, with every placeholder counted as the missing key it stands in for.
|
|
140
|
+
*
|
|
141
|
+
* `auditCatalogs` asks `Object.hasOwn` and nothing else, so a key present with ANY value is not
|
|
142
|
+
* missing — including `⟦key⟧`, which is the value `x i18n sync <defaultLocale>` writes for every
|
|
143
|
+
* gap it closes. Without this, following `X_CATALOG_MISSING_KEYS`'s own `fix:` turns the `i18n`
|
|
144
|
+
* gate step green over strings no human has ever read: issue #249's ending, reached by running the
|
|
145
|
+
* command the error recommends. The hole predates the seeding — a hand-written `"TODO"` bought the
|
|
146
|
+
* same green — but one command now writes sixteen of them, so it is a hole with a shortcut to it.
|
|
147
|
+
*
|
|
148
|
+
* Applied HERE and not in `auditCatalogs`: `⟦…⟧` is what the CLI writes, and `@ultimat3/i18n`'s
|
|
149
|
+
* audit answering "is this key defined" is a different question from "is this app shippable".
|
|
150
|
+
*/
|
|
151
|
+
export function withPlaceholdersMissing(
|
|
152
|
+
report: ExtractReport,
|
|
153
|
+
catalogs: Readonly<Record<Locale, Catalog>>,
|
|
154
|
+
): ExtractReport {
|
|
155
|
+
const locales = report.locales.map((audit) => {
|
|
156
|
+
const catalog = catalogs[audit.locale] ?? {};
|
|
157
|
+
const known = new Set(audit.missing);
|
|
158
|
+
const placeheld = report.used.filter(
|
|
159
|
+
(key) => !known.has(key) && answersWithPlaceholder(catalog, key),
|
|
160
|
+
);
|
|
161
|
+
if (placeheld.length === 0) return audit;
|
|
162
|
+
return { ...audit, missing: [...audit.missing, ...placeheld].sort() };
|
|
163
|
+
});
|
|
164
|
+
return { ...report, locales, ok: locales.every((audit) => audit.missing.length === 0) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The file half: a key source uses that a locale's catalog does not define, or defines with a
|
|
169
|
+
* placeholder. Built here rather than in `cmd-i18n.ts` because `x verify`'s `i18n` step reports
|
|
170
|
+
* the same finding, and a second construction of it is two renderers of one fact waiting to drift.
|
|
111
171
|
*/
|
|
112
172
|
export function missingKeyFindings(report: ExtractReport): readonly Finding[] {
|
|
113
173
|
return report.locales
|
|
@@ -126,5 +186,8 @@ export function missingKeyFindings(report: ExtractReport): readonly Finding[] {
|
|
|
126
186
|
export async function catalogFindings(root: string): Promise<readonly Finding[]> {
|
|
127
187
|
const { report, catalogs, extraction, ignoreUnused } = await auditApp(root);
|
|
128
188
|
const registration = await checkRegistration({ root, catalogs, extraction, ignoreUnused });
|
|
129
|
-
return [
|
|
189
|
+
return [
|
|
190
|
+
...missingKeyFindings(withPlaceholdersMissing(report, catalogs)),
|
|
191
|
+
...registration.findings,
|
|
192
|
+
];
|
|
130
193
|
}
|
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';
|
|
289
|
-
export {
|
|
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';
|
|
326
|
+
export { writeErrorLine, writeLine } from './write-line';
|
package/src/island-bundle.ts
CHANGED
|
@@ -13,6 +13,8 @@ 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';
|
|
16
18
|
import { solidJsxPlugin } from './solid-loader';
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -84,7 +86,13 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
|
|
|
84
86
|
// an island. The app's tsconfig says `jsx: "preserve"`, which makes the bundler fall back to
|
|
85
87
|
// classic `React.createElement` — emitted into a browser chunk that imports no React, with
|
|
86
88
|
// `success: true` and no log. Every island shipped that way through five majors.
|
|
87
|
-
|
|
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],
|
|
88
96
|
});
|
|
89
97
|
} catch (error) {
|
|
90
98
|
throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
|
|
@@ -121,13 +129,64 @@ function describeBuildError(error: unknown): string {
|
|
|
121
129
|
return error instanceof Error ? error.message : String(error);
|
|
122
130
|
}
|
|
123
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
|
+
|
|
124
145
|
/** Build every island in the app. An app with none returns an empty bundle and costs one glob. */
|
|
125
|
-
export async function buildIslands(
|
|
126
|
-
|
|
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);
|
|
127
157
|
const chunks = await Promise.all(files.map((file) => buildOne(root, file)));
|
|
128
158
|
return islandBundle(chunks);
|
|
129
159
|
}
|
|
130
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
|
+
|
|
131
190
|
export function islandBundle(chunks: readonly IslandChunk[]): IslandBundle {
|
|
132
191
|
const byFile = new Map(chunks.map((chunk) => [chunk.file, chunk]));
|
|
133
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/jobs-report.ts
CHANGED
|
@@ -18,23 +18,20 @@ import {
|
|
|
18
18
|
inspectJob,
|
|
19
19
|
inspectJobList,
|
|
20
20
|
inspectQueues,
|
|
21
|
+
isJobState,
|
|
22
|
+
JOB_STATES,
|
|
21
23
|
retryFromStep,
|
|
22
24
|
} from '@ultimat3/jobs';
|
|
23
25
|
import { BadFlagError, JobUnknownError } from './errors';
|
|
24
26
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
'dead',
|
|
34
|
-
];
|
|
35
|
-
|
|
36
|
-
const isJobState = (value: string): value is JobState =>
|
|
37
|
-
(JOB_STATES as readonly string[]).includes(value);
|
|
27
|
+
/**
|
|
28
|
+
* The queue's own vocabulary, re-exported rather than restated — this file carried a copy of it,
|
|
29
|
+
* and the copy was one member short. `cancelled` shipped in `@ultimat3/jobs` and never here, so
|
|
30
|
+
* `x jobs cancel` created a state `x jobs ls --state cancelled` then refused to filter on: two
|
|
31
|
+
* commands of one CLI disagreeing about what a job can be. Kept on this module's surface because
|
|
32
|
+
* `index.ts` exports it from here.
|
|
33
|
+
*/
|
|
34
|
+
export { JOB_STATES } from '@ultimat3/jobs';
|
|
38
35
|
|
|
39
36
|
export function parseStateFlag(value: string | undefined): JobState | undefined {
|
|
40
37
|
if (value === undefined) return undefined;
|
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"',
|
|
@@ -93,6 +102,9 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
93
102
|
X_PORT_IN_USE: 'x dev --port 3001 --json',
|
|
94
103
|
X_DEV_ALREADY_RUNNING:
|
|
95
104
|
'x dev --json # after stopping the x dev that already owns this checkout',
|
|
105
|
+
// The path, not `x dev`: the boot has already refused and rerunning it refuses again. The
|
|
106
|
+
// error's own `fix:` carries the resolved `.x/dev.lock`; this table cannot know the state dir.
|
|
107
|
+
X_DEV_LOCK_UNREADABLE: 'rm .x/dev.lock # then: x dev --json',
|
|
96
108
|
// Not `x db status`: there is no such subcommand (`x db` is gen, migrate, reset, studio, branch),
|
|
97
109
|
// so the fix answered a failed step with X_CLI_UNKNOWN_COMMAND. `x doctor` is what reports
|
|
98
110
|
// reachability and drift, and is already this table's answer for X_DB_STUDIO_FAILED.
|
package/src/messages.ts
CHANGED
|
@@ -139,6 +139,11 @@ const CATALOG = {
|
|
|
139
139
|
// the app has a schema no migration records and `x verify`'s drift step says so until it runs.
|
|
140
140
|
'cli.new.done':
|
|
141
141
|
'created {name} — next: cd {name} && bun install && x db gen "initial" && x db migrate && x dev',
|
|
142
|
+
// The two prose lines of `x new`'s report. The `run: cd … && git init …` line beneath the second
|
|
143
|
+
// one stays inline in `cmd-new.ts`: it is an instruction to paste verbatim, and a translated
|
|
144
|
+
// command is a broken one — the same split `Finding.fix` already makes.
|
|
145
|
+
'cli.new.wrote': ' {count} files in {dir}',
|
|
146
|
+
'cli.new.noRepository': ' no repository — {problem}',
|
|
142
147
|
'cli.policy.count':
|
|
143
148
|
'{permissions} permission(s), {roles} role(s), {enforced} enforced by a declaration',
|
|
144
149
|
// One row per (declaration, actor) pair, never per role: a permission two declarations enforce
|
|
@@ -159,11 +164,82 @@ const CATALOG = {
|
|
|
159
164
|
'cli.routes.empty': 'no routes in the manifest — run `x manifest` first',
|
|
160
165
|
'cli.tasks.count': '{count} task(s)',
|
|
161
166
|
'cli.tasks.shown': '{name} — {cron} ({tz}), next {next}',
|
|
167
|
+
'cli.affected.count':
|
|
168
|
+
'{count} workspace(s) affected by {base}...HEAD, from {changed} changed file(s)',
|
|
169
|
+
'cli.affected.dirty':
|
|
170
|
+
' including the working tree (--dirty): every uncommitted change in this checkout, whoever made it',
|
|
171
|
+
'cli.affected.none': 'no workspace is affected by {base}...HEAD, from {changed} changed file(s)',
|
|
172
|
+
'cli.affected.rootWide':
|
|
173
|
+
' every workspace: {files} belongs to none of them and changes what all of them compile',
|
|
174
|
+
// `x shot` — the picture is the `lines`, the verdict is the artifact. The summary names the
|
|
175
|
+
// GATING fact, and a redirect comes first: a photograph of the sign-in page with every island
|
|
176
|
+
// missing reads as a bug in the app, and it is a bug in the capture.
|
|
177
|
+
'cli.shot.ok': '{route} clean — {islands} island(s) mounted, nothing logged and nothing threw',
|
|
178
|
+
'cli.shot.errors': '{route}: {errors} console error(s) — verdict.json names each one',
|
|
179
|
+
'cli.shot.redirected': '{route} redirected to {url} — the picture is not the route asked for',
|
|
180
|
+
'cli.shot.server.booted': ' server booted for this shot on {url}',
|
|
181
|
+
'cli.shot.server.reused': ' server the x dev already running on {url}',
|
|
182
|
+
'cli.shot.canvas': ' canvas {width}x{height}',
|
|
183
|
+
'cli.shot.canvasUnreadable': ' canvas unreadable — {bytes} byte(s), not a decodable image',
|
|
184
|
+
'cli.shot.islands': ' islands {booted} of {declared} mounted ({strategies})',
|
|
185
|
+
'cli.shot.islandsUnknown': ' islands not counted — the page answered no probe',
|
|
186
|
+
// The failure a picture cannot show and a console count cannot see: a rejected mount promise
|
|
187
|
+
// calls no console method, so the FIRST one is named here rather than left to verdict.json.
|
|
188
|
+
'cli.shot.islandFailed': '{route}: {failed} island(s) failed to mount — {island}: {message}',
|
|
189
|
+
'cli.shot.network': ' network {requests} request(s), {refused} refused, {dropped} dropped',
|
|
190
|
+
'cli.shot.console': ' console {level}: {text}',
|
|
191
|
+
'cli.shot.threw': '{route}: {thrown} uncaught exception(s) — {first}',
|
|
192
|
+
'cli.shot.pageError': ' threw {message} {at}',
|
|
193
|
+
'cli.shot.picture': ' picture {path}',
|
|
194
|
+
'cli.shot.verdict': ' verdict {path}',
|
|
195
|
+
'cli.shot.blind.status':
|
|
196
|
+
'HTTP response status is not observed — the port records requests, never responses',
|
|
197
|
+
'cli.ci.failed':
|
|
198
|
+
'{failed} of {runs} workflow run(s) on {branch} failed — {findings} finding(s) recovered from the log',
|
|
199
|
+
'cli.ci.green': 'every one of {runs} workflow run(s) on {branch} passed',
|
|
200
|
+
'cli.ci.job': ' {conclusion} {job} ({steps})',
|
|
201
|
+
'cli.ci.jobs.other': ' {count} other job(s) in this run',
|
|
202
|
+
'cli.ci.logs.empty': ' the failed step wrote no log — {url}',
|
|
203
|
+
/** The conclusion of a run GitHub has not finished — a value, not a column key. */
|
|
204
|
+
'cli.ci.pending': 'pending',
|
|
205
|
+
'cli.ci.run': '{conclusion} {workflow} {url}',
|
|
206
|
+
'cli.ci.running':
|
|
207
|
+
'{running} of {runs} workflow run(s) on {branch} has not finished — nothing has failed yet',
|
|
208
|
+
'cli.ci.tail': ' log tail, {job}:',
|
|
209
|
+
'cli.pr.body.truncated': ' … {hidden} more line(s) — re-run with --full',
|
|
210
|
+
/** The line of a thread whose anchor GitHub answers null for — a value, not a column key. */
|
|
211
|
+
'cli.pr.line.unknown': '-',
|
|
212
|
+
'cli.pr.replied': 'replied on thread {id}: {url}',
|
|
213
|
+
// Resolving closes a CONVERSATION. Whether the finding is fixed is a fact about the code that
|
|
214
|
+
// no GitHub mutation observes, and a summary saying "addressed" would assert one from the other.
|
|
215
|
+
'cli.pr.resolved':
|
|
216
|
+
'thread {id} is marked resolved on GitHub — that records the conversation, not that the finding is fixed',
|
|
217
|
+
'cli.pr.review.count':
|
|
218
|
+
'{unresolved} unresolved and {resolved} resolved review thread(s) on {repo}#{pr}',
|
|
219
|
+
'cli.pr.review.current': ' submitted against the current head {head}',
|
|
220
|
+
'cli.pr.review.decision': ' review: {decision} by {author} at {submitted}',
|
|
221
|
+
'cli.pr.review.none': 'no review thread is anchored to a line on {repo}#{pr}',
|
|
222
|
+
'cli.pr.review.stale':
|
|
223
|
+
' submitted against {commit}; the head is now {head} ({committed}) — this decision predates the current code',
|
|
224
|
+
'cli.pr.review.truncated':
|
|
225
|
+
' more than {count} threads — this is the first page, not the whole review',
|
|
226
|
+
'cli.pr.review.undecided': ' GitHub reports no review decision yet',
|
|
227
|
+
'cli.pr.thread.closed': ' resolved {path}:{line} {id}',
|
|
228
|
+
'cli.pr.thread.comment': ' {author} at {createdAt}',
|
|
229
|
+
'cli.pr.thread.more': ' {hidden} more comment(s) on this thread',
|
|
230
|
+
'cli.pr.thread.open': ' unresolved {path}:{line} {id}',
|
|
231
|
+
'cli.pr.thread.outdated':
|
|
232
|
+
' the diff has moved under this thread — {line} is where the comment was written',
|
|
162
233
|
'cli.test.fail': '{failed} of {workers} shard(s) failed',
|
|
234
|
+
'cli.test.affected.none': 'nothing is affected by {base}...HEAD — 0 test file(s) ran',
|
|
163
235
|
'cli.test.pass': '{files} test file(s) on {workers} worker(s) passed in {ms}ms',
|
|
164
236
|
'cli.test.sampled': 'sampled {kept} of {total} {type} file(s)',
|
|
165
237
|
'cli.test.type.fail': '{type} — {failed} of {workers} shard(s) failed',
|
|
166
238
|
'cli.test.type.pass': '{type} — {files} test file(s) on {workers} worker(s) passed in {ms}ms',
|
|
239
|
+
// The banner a `--only` run carries, in front of whichever summary above it renders. Rendered
|
|
240
|
+
// output, so it lives here — `data.notAGateRun` is the machine marker, and a reader testing for
|
|
241
|
+
// one narrowed run reads that boolean rather than substring-matching this line.
|
|
242
|
+
'cli.verify.notAGateRun': 'NOT A GATE RUN — {summary}',
|
|
167
243
|
'cli.verify.pass': 'all {count} steps passed in {ms}ms',
|
|
168
244
|
'cli.verify.fail': '{failed} of {count} steps failed',
|
|
169
245
|
// A skipped step is not a passed one, so the two counts never share a sentence — and the skipped
|
package/src/output.ts
CHANGED
|
@@ -52,6 +52,17 @@ export interface CommandResult {
|
|
|
52
52
|
* could show is how the two drift.
|
|
53
53
|
*/
|
|
54
54
|
readonly hold?: () => Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Which fd this result is written to. `stdout` for every command, absent included — and
|
|
57
|
+
* `stderr` for the one case where fd 1 is not the command's to write on: `x mcp serve
|
|
58
|
+
* --transport stdio`, whose stdout carries JSON-RPC frames, and where the `✓ mcp stdio serving
|
|
59
|
+
* 13 tools` line printed after the loop exits is a malformed frame to whatever is reading.
|
|
60
|
+
*
|
|
61
|
+
* Behaviour, not a fact, exactly like `hold` above — so NEITHER renderer carries it. It says
|
|
62
|
+
* where a rendered line goes, and a payload that also claimed it would be a second answer to a
|
|
63
|
+
* question `dispatch` has already answered by choosing the sink.
|
|
64
|
+
*/
|
|
65
|
+
readonly stream?: 'stdout' | 'stderr';
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
export interface UltimateErrorShape {
|
|
@@ -158,13 +169,22 @@ export function renderHuman(result: CommandResult, verbose = false): string {
|
|
|
158
169
|
for (const step of result.steps ?? []) {
|
|
159
170
|
out.push(` ${mark(step)} ${step.name.padEnd(18)} ${step.durationMs}ms${width(step)}`);
|
|
160
171
|
for (const finding of step.findings) out.push(renderFinding(finding, ' '));
|
|
172
|
+
// NOT escaped, and that is the one exception: `output` is this process's own captured
|
|
173
|
+
// subprocess stdout — `bun test`'s colour is the reason a human reads it at all, and it is
|
|
174
|
+
// already split on its real newlines rather than carrying them inside one entry.
|
|
161
175
|
if (step.output !== undefined && step.output.length > 0 && (verbose || !step.ok)) {
|
|
162
176
|
for (const line of step.output.trimEnd().split('\n')) out.push(` | ${line}`);
|
|
163
177
|
}
|
|
164
178
|
}
|
|
165
|
-
|
|
179
|
+
// Every free-text line through the SAME `singleLine` the 3-line format runs, because this is
|
|
180
|
+
// where text the CLI did not write reaches fd 1: a GitHub review body (`x pr`), a CI log tail
|
|
181
|
+
// (`x ci`), a page's own console (`x shot`). It was emitted verbatim, so an ESC byte in a PR
|
|
182
|
+
// comment retitled the window and cleared the screen, and a newline in one entry printed a
|
|
183
|
+
// second line a reader — or the agent this command exists for — takes for the renderer's own.
|
|
184
|
+
for (const line of result.lines ?? []) out.push(singleLine(line));
|
|
166
185
|
for (const finding of result.findings ?? []) out.push(renderFinding(finding, ' '));
|
|
167
|
-
|
|
186
|
+
// The summary is foreign too: `cli.shot.threw` interpolates a page's own error message.
|
|
187
|
+
out.push(`${result.ok ? '✓' : '✗'} ${singleLine(result.summary)}`);
|
|
168
188
|
return out.join('\n');
|
|
169
189
|
}
|
|
170
190
|
|