@ultimat3/cli 22.15.0 → 23.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 +23 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +25 -1
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +9 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/permission-grants.ts +41 -9
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +6 -0
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -157
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +142 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
package/src/app-load.ts
CHANGED
|
@@ -151,7 +151,31 @@ export async function appModulePaths(root: string): Promise<readonly string[]> {
|
|
|
151
151
|
return found.includes(index) ? [index, ...found.filter((path) => path !== index)] : found;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
/** What one scan of the app's modules imported, and what would not. */
|
|
155
|
+
export interface AppScan {
|
|
156
|
+
/** App-root-relative POSIX paths of every module that imported, sorted. */
|
|
157
|
+
readonly files: readonly string[];
|
|
158
|
+
/** Modules that would not import, and primitives that would not register. */
|
|
159
|
+
readonly findings: readonly Finding[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ScanOptions {
|
|
163
|
+
/**
|
|
164
|
+
* Records each module's bytes and pins so a LATER scan can evict what changed: a tool's and
|
|
165
|
+
* `x dev`'s. A container scans once per process, so its boot passes `false` and neither reads a
|
|
166
|
+
* module before importing it nor hashes one (`role-load.ts`).
|
|
167
|
+
*/
|
|
168
|
+
readonly track: boolean;
|
|
169
|
+
/** `false` leaves a module out: neither read nor imported. Absent: every module is imported. */
|
|
170
|
+
readonly include?: (absolute: string) => boolean | Promise<boolean>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The scan itself: every app module `options.include` admits, imported in `appModulePaths` order
|
|
175
|
+
* and registered. `loadApp` is this plus the facts a tool reads; a container role calls it through
|
|
176
|
+
* `loadAppForRole`, which decides what the role imports.
|
|
177
|
+
*/
|
|
178
|
+
export async function scanAppModules(root: string, options: ScanOptions): Promise<AppScan> {
|
|
155
179
|
// Before any import: a stylesheet's surface is read below the app root, never off its absolute
|
|
156
180
|
// path — under the container's `WORKDIR /app` that path's first segment is `app/`, and every
|
|
157
181
|
// sheet, the site's included, classified as app CSS.
|
|
@@ -164,7 +188,7 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
164
188
|
const findings: Finding[] = [];
|
|
165
189
|
// A rescan: whatever changed on disk since the last one, and everything importing it, leaves
|
|
166
190
|
// Bun's module registry before the loop below imports it again.
|
|
167
|
-
if (registered.size > 0) await evictChanged(root);
|
|
191
|
+
if (options.track && registered.size > 0) await evictChanged(root);
|
|
168
192
|
|
|
169
193
|
for (const absolute of await appModulePaths(root)) {
|
|
170
194
|
// A SEGMENT, never a substring: an app checked out under
|
|
@@ -177,15 +201,19 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
177
201
|
if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
|
|
178
202
|
continue;
|
|
179
203
|
}
|
|
204
|
+
if (options.include !== undefined && !(await options.include(absolute))) continue;
|
|
180
205
|
// The source is read BEFORE the import, on the file's first pass and for every route module —
|
|
181
206
|
// a rescan of any other registered module reads nothing here. What is recorded is bound to the
|
|
182
207
|
// bytes the module was evaluated from, and a read AFTER the import cannot know which bytes
|
|
183
208
|
// those were: a save landing between the two bound V1's component to V2's hash, so the next
|
|
184
209
|
// scan saw nothing to do and served V1 until the save after. Read first, the worst case is one
|
|
185
|
-
// re-import the next tick, of a file that did change.
|
|
210
|
+
// re-import the next tick, of a file that did change. An untracked scan reads nothing here:
|
|
211
|
+
// no save lands in an image, so a route module's text is read after its import, once.
|
|
186
212
|
const first = !registered.has(absolute) && !failures.has(absolute);
|
|
187
213
|
const snapshot =
|
|
188
|
-
first || routeModules.has(absolute)
|
|
214
|
+
options.track && (first || routeModules.has(absolute))
|
|
215
|
+
? await Bun.file(absolute).text()
|
|
216
|
+
: undefined;
|
|
189
217
|
if (first && snapshot !== undefined) await trackModule(absolute, snapshot, root);
|
|
190
218
|
let module: Record<string, unknown>;
|
|
191
219
|
try {
|
|
@@ -195,16 +223,26 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
195
223
|
continue;
|
|
196
224
|
}
|
|
197
225
|
files.push(file);
|
|
198
|
-
const finding = await register(absolute, file, module, snapshot);
|
|
226
|
+
const finding = await register(absolute, file, module, snapshot, options.track);
|
|
199
227
|
if (finding !== undefined) findings.push(finding);
|
|
200
228
|
}
|
|
201
229
|
|
|
202
230
|
files.sort();
|
|
231
|
+
return { files, findings };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function loadApp(root: string): Promise<LoadedApp> {
|
|
235
|
+
const scan = await scanAppModules(root, { track: true });
|
|
236
|
+
// After the scan, because the scan is what mounts an admin: a declaration the globs above cannot
|
|
237
|
+
// reach is a finding here, owned by `manifest` like every other load finding. Imported here, not
|
|
238
|
+
// at the top: a container boots through `scanAppModules` and never asks (`serve-graph.test.ts`).
|
|
239
|
+
const { unscannedAdminFindings } = await import('./unscanned-admin');
|
|
240
|
+
const findings = [...scan.findings, ...(await unscannedAdminFindings(root))];
|
|
203
241
|
await trackStylesheets(root);
|
|
204
242
|
// An app that imported NOTHING and reported nothing is a registry every later step reads as
|
|
205
243
|
// empty-and-fine. With an `app.config.ts` beside it, that is never what the author meant.
|
|
206
244
|
if (
|
|
207
|
-
files.length === 0 &&
|
|
245
|
+
scan.files.length === 0 &&
|
|
208
246
|
findings.length === 0 &&
|
|
209
247
|
registersNothing() &&
|
|
210
248
|
(await Bun.file(join(root, APP_CONFIG_FILE)).exists())
|
|
@@ -214,7 +252,7 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
214
252
|
// Read after the loop, never before it: `configureLocales` runs on the app's own import.
|
|
215
253
|
return {
|
|
216
254
|
root,
|
|
217
|
-
files,
|
|
255
|
+
files: scan.files,
|
|
218
256
|
errorCodes: await appErrorCodes(root),
|
|
219
257
|
defaultLocale: localeConfig().fallback,
|
|
220
258
|
findings,
|
|
@@ -240,13 +278,15 @@ const emptyAppFinding = (root: string): Finding => ({
|
|
|
240
278
|
/**
|
|
241
279
|
* Registers a module once; every later call replays whatever the first one reported — except for
|
|
242
280
|
* a route module, which a later call re-registers when the import handed back a new instance.
|
|
243
|
-
* `snapshot` is the source read before this import: always for a first pass and a route module
|
|
281
|
+
* `snapshot` is the source read before this import: always for a first pass and a route module of
|
|
282
|
+
* a TRACKED scan. An untracked one hands none, and a route module's text is read here instead.
|
|
244
283
|
*/
|
|
245
284
|
async function register(
|
|
246
285
|
absolute: string,
|
|
247
286
|
file: string,
|
|
248
287
|
module: Record<string, unknown>,
|
|
249
288
|
snapshot: string | undefined,
|
|
289
|
+
track: boolean,
|
|
250
290
|
): Promise<Finding | undefined> {
|
|
251
291
|
const previous = failures.get(absolute);
|
|
252
292
|
if (previous !== undefined) return previous;
|
|
@@ -255,11 +295,15 @@ async function register(
|
|
|
255
295
|
try {
|
|
256
296
|
const config = module['config'];
|
|
257
297
|
const route = isRouteConfig(config) ? config : undefined;
|
|
258
|
-
if (route !== undefined
|
|
259
|
-
registerRouteModule(file, module, route, snapshot);
|
|
298
|
+
if (route !== undefined) {
|
|
299
|
+
registerRouteModule(file, module, route, snapshot ?? (await Bun.file(absolute).text()));
|
|
260
300
|
routeModules.set(absolute, module);
|
|
261
301
|
}
|
|
262
|
-
|
|
302
|
+
// A pin is the reload graph's: it stops an eviction a process that never rescans never runs,
|
|
303
|
+
// and deciding it walks every export of every module against the entity registry.
|
|
304
|
+
if (track && (definesPrimitive(module, route !== undefined) || file === API_INDEX)) {
|
|
305
|
+
pinModule(absolute);
|
|
306
|
+
}
|
|
263
307
|
registerActions(module);
|
|
264
308
|
registerQueries(module);
|
|
265
309
|
return undefined;
|
package/src/app-manifest.ts
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
// Bun ships no `Bun.*` path API: `join` builds the host-separator path to `x.manifest.json`.
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { describeActions } from '@ultimat3/action';
|
|
9
|
+
import { primitiveRegistrar } from '@ultimat3/core';
|
|
9
10
|
import { registeredLocales } from '@ultimat3/i18n';
|
|
10
11
|
import { registeredTasks } from '@ultimat3/jobs';
|
|
12
|
+
import { sendMailJob } from '@ultimat3/mail';
|
|
11
13
|
import type { Manifest, PolicyFact, RouteFact, TaskFact } from '@ultimat3/manifest';
|
|
12
14
|
import {
|
|
13
15
|
buildManifest,
|
|
@@ -30,9 +32,21 @@ export interface AppManifest {
|
|
|
30
32
|
readonly findings: readonly Finding[];
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The jobs every role process registers whatever the app imports: `runtime-services.ts` imports
|
|
37
|
+
* `@ultimat3/mail` in every role, and that import declares `mail.send` — so every app's worker
|
|
38
|
+
* runs it, and the manifest records it. Seated here rather than left to an import's side effect:
|
|
39
|
+
* `x manifest`'s module graph never reached the mail barrel and `x verify`'s did, so the same app
|
|
40
|
+
* got two job lists and the `fix:` of `X_MANIFEST_DRIFT` could never clear it.
|
|
41
|
+
*/
|
|
42
|
+
const RUNTIME_JOBS: Readonly<Record<string, unknown>> = { sendMailJob };
|
|
43
|
+
|
|
33
44
|
/** Load the app, then describe it. Every command that needs facts goes through here. */
|
|
34
45
|
export async function appManifest(root: string): Promise<AppManifest> {
|
|
35
46
|
const loaded = await loadApp(root);
|
|
47
|
+
// Through the registrar `defineApi` uses (`define-api.ts`): the declared name wins, and the
|
|
48
|
+
// same handle seated twice is one registration, so a second build of one tree is unchanged.
|
|
49
|
+
primitiveRegistrar('job')(RUNTIME_JOBS);
|
|
36
50
|
const manifest = buildManifest(
|
|
37
51
|
frameworkSources({
|
|
38
52
|
app: await appIdentity(root),
|
package/src/app-permissions.ts
CHANGED
|
Binary file
|
package/src/app-reload-graph.ts
CHANGED
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
// the old instance of what changed (a slice's service under its query, an admin page's view under
|
|
21
21
|
// `defineAdmin`), so only a new process serves the save. `x dev` restarts on it (`dev-supervisor.ts`).
|
|
22
22
|
|
|
23
|
-
// why: Bun ships no path API;
|
|
24
|
-
import {
|
|
23
|
+
// why: Bun ships no path API; the app root is compared as an absolute path.
|
|
24
|
+
import { resolve } from 'node:path';
|
|
25
25
|
import { registeredStylesheets } from '@ultimat3/render/server';
|
|
26
|
+
import { resolvedImports } from './module-imports';
|
|
26
27
|
import { hasPathSegment } from './path-segments';
|
|
27
28
|
|
|
28
29
|
type Kind = 'script' | 'style' | 'data';
|
|
@@ -91,11 +92,6 @@ const STYLE = /\.(?:s[ac]ss|css)$/;
|
|
|
91
92
|
const kindOf = (path: string): Kind =>
|
|
92
93
|
SCRIPT.test(path) ? 'script' : STYLE.test(path) ? 'style' : 'data';
|
|
93
94
|
|
|
94
|
-
const transpilers = {
|
|
95
|
-
ts: new Bun.Transpiler({ loader: 'ts' }),
|
|
96
|
-
tsx: new Bun.Transpiler({ loader: 'tsx' }),
|
|
97
|
-
};
|
|
98
|
-
|
|
99
95
|
const readText = async (path: string): Promise<string | null> => {
|
|
100
96
|
try {
|
|
101
97
|
return await Bun.file(path).text();
|
|
@@ -108,29 +104,10 @@ const hashOf = (text: string | null): bigint | null =>
|
|
|
108
104
|
text === null ? null : BigInt(Bun.hash.wyhash(text));
|
|
109
105
|
|
|
110
106
|
/** The in-app files `source` imports — a package under `node_modules` is not the app's to reload. */
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
specifiers = transpiler.scanImports(source);
|
|
116
|
-
} catch {
|
|
117
|
-
// A file that will not parse imports nothing yet; its own hash still changes on the fix.
|
|
118
|
-
return [];
|
|
119
|
-
}
|
|
120
|
-
const found: string[] = [];
|
|
121
|
-
for (const { path: specifier } of specifiers) {
|
|
122
|
-
let target: string;
|
|
123
|
-
try {
|
|
124
|
-
target = Bun.resolveSync(specifier, dirname(path));
|
|
125
|
-
} catch {
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
if (target.startsWith(`${root}/`) && !hasPathSegment(target, 'node_modules')) {
|
|
129
|
-
found.push(target);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return found;
|
|
133
|
-
}
|
|
107
|
+
const importsOf = (path: string, source: string, root: string): string[] =>
|
|
108
|
+
resolvedImports(path, source, 'referenced').filter(
|
|
109
|
+
(target) => target.startsWith(`${root}/`) && !hasPathSegment(target, 'node_modules'),
|
|
110
|
+
);
|
|
134
111
|
|
|
135
112
|
function link(from: string, to: string): void {
|
|
136
113
|
let set = importers.get(to);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// The browser-transport rule (`browser-transport.ts`) over ONE APP: what `x verify`'s `boundaries`
|
|
2
|
+
// step reports for a raw `fetch(` in an island. Built in rather than a file in `guards/` — a rule
|
|
3
|
+
// an app can delete is a rule the next agent deletes.
|
|
4
|
+
//
|
|
5
|
+
// It reads nothing `readAppSources` already read. The islands and the surface files arrive in
|
|
6
|
+
// memory; the only new I/O is the app's `tsconfig.json`, its workspace manifests, the installed
|
|
7
|
+
// `@ultimat3/*` manifests (which name the browser entries), and — lazily — the files of the app's
|
|
8
|
+
// own `packages/*` an island actually imports. An app with no island pays for none of it.
|
|
9
|
+
//
|
|
10
|
+
// The walk STOPS at an installed package: `@ultimat3/core`'s `clientTransport`, realtime's socket
|
|
11
|
+
// and storage's upload client are the three seams, they live in `node_modules`, and they are
|
|
12
|
+
// trusted rather than re-read — in an app, no file may open its own connection.
|
|
13
|
+
|
|
14
|
+
// why: `ClosureHost.read` is synchronous (the walk is a plain loop) and `Bun.file().text()` is not.
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
// why: Bun ships no path API; this reaches a file on disk with the host's separator.
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { isJsonObject } from '@ultimat3/core';
|
|
19
|
+
import type { SourceFile } from './app-boundaries';
|
|
20
|
+
import type { FindingContext } from './browser-transport';
|
|
21
|
+
import {
|
|
22
|
+
barrelFinding,
|
|
23
|
+
browserEntries,
|
|
24
|
+
bypassFinding,
|
|
25
|
+
checkBrowserTransport,
|
|
26
|
+
} from './browser-transport';
|
|
27
|
+
import type { ClosureHost } from './import-closure';
|
|
28
|
+
import type { Finding } from './output';
|
|
29
|
+
import { hasPathSegment } from './path-segments';
|
|
30
|
+
import { serverBarrels } from './server-barrels';
|
|
31
|
+
import type { TransportShape } from './transport-calls';
|
|
32
|
+
|
|
33
|
+
/** What an app is told may hold each shape: the framework's function, never a file of its own. */
|
|
34
|
+
const HOLDER: ReadonlyMap<TransportShape, string> = new Map([
|
|
35
|
+
['fetch', "@ultimat3/core's clientTransport"],
|
|
36
|
+
['websocket', "@ultimat3/realtime's page socket"],
|
|
37
|
+
['xhr', "@ultimat3/storage's uploadFile"],
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const CONTEXT: FindingContext = {
|
|
41
|
+
holder: (shape) => HOLDER.get(shape),
|
|
42
|
+
rerun: 'x verify --only boundaries --json',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** One `paths` row or one `exports` row, as a specifier pattern: at most one `*` on each side. */
|
|
46
|
+
interface Pattern {
|
|
47
|
+
readonly head: string;
|
|
48
|
+
readonly tail: string;
|
|
49
|
+
readonly wild: boolean;
|
|
50
|
+
/** Root-relative, `*` still in place. */
|
|
51
|
+
readonly target: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const patternOf = (key: string, target: string): Pattern => {
|
|
55
|
+
const star = key.indexOf('*');
|
|
56
|
+
return star < 0
|
|
57
|
+
? { head: key, tail: '', wild: false, target }
|
|
58
|
+
: { head: key.slice(0, star), tail: key.slice(star + 1), wild: true, target };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The path `spec` names under `pattern`, or `undefined` when the pattern is not about it. */
|
|
62
|
+
function applied(pattern: Pattern, spec: string): string | undefined {
|
|
63
|
+
if (!pattern.wild) return spec === pattern.head ? pattern.target : undefined;
|
|
64
|
+
if (!spec.startsWith(pattern.head) || !spec.endsWith(pattern.tail)) return undefined;
|
|
65
|
+
if (spec.length < pattern.head.length + pattern.tail.length) return undefined;
|
|
66
|
+
const middle = spec.slice(pattern.head.length, spec.length - pattern.tail.length);
|
|
67
|
+
return pattern.target.replace('*', middle);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const posix = (path: string): string => path.split('\\').join('/').replace(/^\.\//, '');
|
|
71
|
+
|
|
72
|
+
/** A manifest or a tsconfig, or `undefined`: absent and unreadable both mean "says nothing". */
|
|
73
|
+
async function readObject(path: string): Promise<Readonly<Record<string, unknown>> | undefined> {
|
|
74
|
+
const file = Bun.file(path);
|
|
75
|
+
if (!(await file.exists())) return undefined;
|
|
76
|
+
try {
|
|
77
|
+
// JSONC: a tsconfig may carry comments and trailing commas, and a strict parse would turn one
|
|
78
|
+
// into "this app has no aliases" — every island's imports unresolved, the rule silently blind.
|
|
79
|
+
const parsed: unknown = Bun.JSONC.parse(await file.text());
|
|
80
|
+
return isJsonObject(parsed) ? parsed : undefined;
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `compilerOptions.paths` of the app's root tsconfig — how its own packages are spelled. */
|
|
87
|
+
async function tsconfigPatterns(root: string): Promise<readonly Pattern[]> {
|
|
88
|
+
const options = (await readObject(join(root, 'tsconfig.json')))?.['compilerOptions'];
|
|
89
|
+
const paths = isJsonObject(options) ? options['paths'] : undefined;
|
|
90
|
+
if (!isJsonObject(paths)) return [];
|
|
91
|
+
return Object.entries(paths).flatMap(([key, targets]) => {
|
|
92
|
+
const first: unknown = Array.isArray(targets) ? targets[0] : undefined;
|
|
93
|
+
return typeof first === 'string' ? [patternOf(key, posix(first))] : [];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface Workspaces {
|
|
98
|
+
/** Every specifier a workspace package publishes, as a pattern onto its file. */
|
|
99
|
+
readonly patterns: readonly Pattern[];
|
|
100
|
+
/** Package name → its directory, for "is this file inside the package that barrel names". */
|
|
101
|
+
readonly dirs: ReadonlyMap<string, string>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The app's own `apps/*` and `packages/*`, by the `name` and `exports` each manifest declares. */
|
|
105
|
+
async function workspaces(root: string): Promise<Workspaces> {
|
|
106
|
+
const patterns: Pattern[] = [];
|
|
107
|
+
const dirs = new Map<string, string>();
|
|
108
|
+
for await (const manifest of new Bun.Glob('{apps,packages}/*/package.json').scan({ cwd: root })) {
|
|
109
|
+
const dir = posix(manifest).split('/').slice(0, 2).join('/');
|
|
110
|
+
const parsed = await readObject(join(root, manifest));
|
|
111
|
+
const name = parsed?.['name'];
|
|
112
|
+
if (typeof name !== 'string') continue;
|
|
113
|
+
dirs.set(name, dir);
|
|
114
|
+
const exports = isJsonObject(parsed?.['exports']) ? parsed['exports'] : {};
|
|
115
|
+
for (const [sub, target] of Object.entries(exports)) {
|
|
116
|
+
if (typeof target !== 'string') continue;
|
|
117
|
+
const spec = sub === '.' ? name : `${name}${sub.slice(1)}`;
|
|
118
|
+
patterns.push(patternOf(spec, `${dir}/${posix(target)}`));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { patterns, dirs };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const INSTALLED = [
|
|
125
|
+
'node_modules/@ultimat3/*/package.json',
|
|
126
|
+
// A workspace install may leave a package beside the workspace that asked for it.
|
|
127
|
+
'{apps,packages}/*/node_modules/@ultimat3/*/package.json',
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
/** Every specifier the installed framework publishes — what `serverBarrels` derives its map from. */
|
|
131
|
+
async function installedSpecifiers(root: string): Promise<ReadonlySet<string>> {
|
|
132
|
+
const specifiers = new Set<string>();
|
|
133
|
+
for (const pattern of INSTALLED) {
|
|
134
|
+
const found = new Bun.Glob(pattern).scan({ cwd: root, followSymlinks: true });
|
|
135
|
+
for await (const manifest of found) {
|
|
136
|
+
const parsed = await readObject(join(root, manifest));
|
|
137
|
+
const name = parsed?.['name'];
|
|
138
|
+
if (typeof name !== 'string') continue;
|
|
139
|
+
const exports = isJsonObject(parsed?.['exports']) ? parsed['exports'] : {};
|
|
140
|
+
for (const sub of Object.keys(exports)) {
|
|
141
|
+
if (!sub.includes('*')) specifiers.add(sub === '.' ? name : `${name}${sub.slice(1)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return specifiers;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const SUFFIXES = ['', '.ts', '.tsx', '/index.ts', '/index.tsx'];
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The closure's view of the app: the surface files from memory, a file of the app's own
|
|
152
|
+
* `packages/*` from disk the first time an island reaches it, and nothing under `node_modules` —
|
|
153
|
+
* a relative import that climbs into an installed package is still past the boundary.
|
|
154
|
+
*/
|
|
155
|
+
function hostOf(
|
|
156
|
+
root: string,
|
|
157
|
+
sources: ReadonlyMap<string, string>,
|
|
158
|
+
patterns: readonly Pattern[],
|
|
159
|
+
): ClosureHost {
|
|
160
|
+
const disk = new Map<string, string | undefined>();
|
|
161
|
+
const read = (path: string): string | undefined => {
|
|
162
|
+
const held = sources.get(path);
|
|
163
|
+
if (held !== undefined) return held;
|
|
164
|
+
if (!/\.tsx?$/.test(path) || hasPathSegment(path, 'node_modules')) return undefined;
|
|
165
|
+
if (!disk.has(path)) {
|
|
166
|
+
try {
|
|
167
|
+
disk.set(path, readFileSync(join(root, path), 'utf8'));
|
|
168
|
+
} catch {
|
|
169
|
+
disk.set(path, undefined);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return disk.get(path);
|
|
173
|
+
};
|
|
174
|
+
const alias = (spec: string): string | undefined => {
|
|
175
|
+
for (const pattern of patterns) {
|
|
176
|
+
const base = applied(pattern, spec);
|
|
177
|
+
if (base === undefined) continue;
|
|
178
|
+
for (const suffix of SUFFIXES) {
|
|
179
|
+
if (read(`${base}${suffix}`) !== undefined) return `${base}${suffix}`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
};
|
|
184
|
+
return { read, alias };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* `X_BROWSER_TRANSPORT_BYPASS` and `X_BROWSER_SERVER_BARREL` for the app at `root`, over the
|
|
189
|
+
* sources `readAppSources` already holds.
|
|
190
|
+
*/
|
|
191
|
+
export async function appTransportFindings(
|
|
192
|
+
root: string,
|
|
193
|
+
files: readonly SourceFile[],
|
|
194
|
+
): Promise<readonly Finding[]> {
|
|
195
|
+
const sources = new Map(files.map((file) => [file.path, file.source]));
|
|
196
|
+
const entries = browserEntries(sources);
|
|
197
|
+
if (entries.length === 0) return [];
|
|
198
|
+
const [paths, own, installed] = await Promise.all([
|
|
199
|
+
tsconfigPatterns(root),
|
|
200
|
+
workspaces(root),
|
|
201
|
+
installedSpecifiers(root),
|
|
202
|
+
]);
|
|
203
|
+
const published = [...installed, ...own.patterns.filter((p) => !p.wild).map((p) => p.head)];
|
|
204
|
+
const { bypasses, barrels } = checkBrowserTransport({
|
|
205
|
+
entries,
|
|
206
|
+
// The tsconfig first: it is what the app's own typecheck resolves through.
|
|
207
|
+
host: hostOf(root, sources, [...paths, ...own.patterns]),
|
|
208
|
+
seams: new Map(),
|
|
209
|
+
barrels: serverBarrels(published),
|
|
210
|
+
ownsBarrel: (file, barrel) => {
|
|
211
|
+
const dir = own.dirs.get(barrel);
|
|
212
|
+
return dir !== undefined && file.startsWith(`${dir}/`);
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
return [
|
|
216
|
+
...bypasses.map((bypass) => bypassFinding(bypass, CONTEXT)),
|
|
217
|
+
...barrels.map((barrel) => barrelFinding(barrel, CONTEXT)),
|
|
218
|
+
];
|
|
219
|
+
}
|
package/src/boundary-findings.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// was another command to run for the real one (plan 101 slice 11 j). The planner is the same one
|
|
4
4
|
// `x fix boundary` prints from, so the gate and the command cannot disagree about the cut.
|
|
5
5
|
|
|
6
|
+
import type { SourceFile } from './app-boundaries';
|
|
6
7
|
import { appImportGraph, checkImportRules, readAppSources } from './app-boundaries';
|
|
8
|
+
import { appTransportFindings } from './app-transport';
|
|
7
9
|
import { planBoundaryCuts } from './boundary-cuts';
|
|
8
10
|
import type { Finding } from './output';
|
|
9
11
|
|
|
@@ -29,8 +31,18 @@ export function withCutEdits(
|
|
|
29
31
|
});
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Read the app's sources once, check them, and hand back findings whose fix is the edit. The
|
|
36
|
+
* browser-transport findings come from the same read and already carry their own edit.
|
|
37
|
+
*/
|
|
38
|
+
export async function appBoundaryFindings(
|
|
39
|
+
root: string,
|
|
40
|
+
/** The app's sources when the caller has already read them — the gate step reads them once. */
|
|
41
|
+
read?: readonly SourceFile[],
|
|
42
|
+
): Promise<readonly Finding[]> {
|
|
43
|
+
const files = read ?? (await readAppSources(root));
|
|
44
|
+
return [
|
|
45
|
+
...withCutEdits(checkImportRules(files), appImportGraph(files)),
|
|
46
|
+
...(await appTransportFindings(root, files)),
|
|
47
|
+
];
|
|
36
48
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// The browser has ONE HTTP function and ONE socket, as a rule over a file tree: a raw `fetch(`,
|
|
2
|
+
// `new WebSocket(`, `new XMLHttpRequest(` or `new EventSource(` in browser-reachable code skips
|
|
3
|
+
// `@ultimat3/core`'s `clientTransport` — credentials, the idempotency header, the record envelope,
|
|
4
|
+
// the principal fence — so its rows never reach the page's store and it keeps running for the
|
|
5
|
+
// previous principal after a sign-out. A package's SERVER BARREL in that closure is refused too.
|
|
6
|
+
//
|
|
7
|
+
// BROWSER-REACHABLE is derived, never listed: every `*.island.tsx`, every module that names the
|
|
8
|
+
// client seam itself, and the import closure of each, followed name by name through barrels
|
|
9
|
+
// (`import-closure.ts`). Pure over the tree its caller read — `app-transport.ts` reads an app's,
|
|
10
|
+
// `scripts/browser-transport.ts` this repository's.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT CANNOT SEE: a browser module that neither is an island, nor names the seam, nor is
|
|
13
|
+
// imported by either; a request made inside an installed package (the walk ends at the package
|
|
14
|
+
// boundary); a fetch reached through a computed member (`globalThis['fetch']`); and a bare
|
|
15
|
+
// `fetch(` in a file that also binds a local `fetch` (`bindsFetch`). A floor, not a proof.
|
|
16
|
+
|
|
17
|
+
import { ERROR_DOCS_URL, maskLiterals } from '@ultimat3/core';
|
|
18
|
+
import { commentSafe } from './comment-safe';
|
|
19
|
+
import type { ClosureHost } from './import-closure';
|
|
20
|
+
import { importClosure } from './import-closure';
|
|
21
|
+
import type { Finding } from './output';
|
|
22
|
+
import type { BarrelImport } from './server-barrels';
|
|
23
|
+
import { barrelImports } from './server-barrels';
|
|
24
|
+
import { isTest } from './source-files';
|
|
25
|
+
import type { TransportCall, TransportShape } from './transport-calls';
|
|
26
|
+
import { transportCalls } from './transport-calls';
|
|
27
|
+
|
|
28
|
+
export interface Bypass extends TransportCall {
|
|
29
|
+
readonly file: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ServerBarrel extends BarrelImport {
|
|
33
|
+
readonly file: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const isIsland = (path: string): boolean => path.endsWith('.island.tsx');
|
|
37
|
+
|
|
38
|
+
/** A module naming the page's client seam is browser code by its own statement. */
|
|
39
|
+
const NAMES_SEAM = /\b(?:clientTransport|pageClient)\s*[(<]/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where the closure starts: every island, plus every module `namesSeam` admits that calls the seam
|
|
43
|
+
* itself. Masked before the test: a template that EMITS `clientTransport(` is not browser code.
|
|
44
|
+
*/
|
|
45
|
+
export function browserEntries(
|
|
46
|
+
files: ReadonlyMap<string, string>,
|
|
47
|
+
namesSeam: (path: string) => boolean = () => true,
|
|
48
|
+
): readonly string[] {
|
|
49
|
+
return [...files.entries()]
|
|
50
|
+
.filter(([path, source]) => {
|
|
51
|
+
if (isTest(path)) return false;
|
|
52
|
+
if (isIsland(path)) return true;
|
|
53
|
+
// The raw text first: masking is a pass over every character, and almost no file names it.
|
|
54
|
+
return namesSeam(path) && NAMES_SEAM.test(source) && NAMES_SEAM.test(maskLiterals(source));
|
|
55
|
+
})
|
|
56
|
+
.map(([path]) => path)
|
|
57
|
+
.sort();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface TransportInput {
|
|
61
|
+
readonly entries: readonly string[];
|
|
62
|
+
/** Reads a file and resolves a bare specifier; `undefined` from either ends the walk there. */
|
|
63
|
+
readonly host: ClosureHost;
|
|
64
|
+
/**
|
|
65
|
+
* Shape → the ONE file in this tree allowed to hold it. Empty for an app: the seams are the
|
|
66
|
+
* framework's and live in `node_modules`, past the boundary the walk stops at.
|
|
67
|
+
*/
|
|
68
|
+
readonly seams: ReadonlyMap<TransportShape, string>;
|
|
69
|
+
/** Files whose realm is not a page — a service worker has no store and no principal fence. */
|
|
70
|
+
readonly exempt?: ReadonlySet<string>;
|
|
71
|
+
/** Server barrel → the browser entry that replaces it (`serverBarrels`). */
|
|
72
|
+
readonly barrels: ReadonlyMap<string, string>;
|
|
73
|
+
/** A package's own modules may import its own barrel; only a FOREIGN import pays for it. */
|
|
74
|
+
readonly ownsBarrel?: (file: string, barrel: string) => boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The whole rule over a tree, pure. */
|
|
78
|
+
export function checkBrowserTransport(input: TransportInput): {
|
|
79
|
+
readonly reachable: readonly string[];
|
|
80
|
+
readonly bypasses: readonly Bypass[];
|
|
81
|
+
readonly barrels: readonly ServerBarrel[];
|
|
82
|
+
} {
|
|
83
|
+
const reachable = importClosure(input.host, input.entries);
|
|
84
|
+
const bypasses: Bypass[] = [];
|
|
85
|
+
const barrels: ServerBarrel[] = [];
|
|
86
|
+
for (const file of reachable) {
|
|
87
|
+
if (input.exempt?.has(file) === true) continue;
|
|
88
|
+
const source = input.host.read(file) ?? '';
|
|
89
|
+
for (const call of transportCalls(source)) {
|
|
90
|
+
if (input.seams.get(call.shape) === file) continue;
|
|
91
|
+
bypasses.push({ ...call, file });
|
|
92
|
+
}
|
|
93
|
+
for (const found of barrelImports(source, input.barrels)) {
|
|
94
|
+
if (input.ownsBarrel?.(file, found.barrel) === true) continue;
|
|
95
|
+
barrels.push({ ...found, file });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { reachable, bypasses, barrels };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What to write instead, per shape — each a line that runs once its placeholder is named. A raw
|
|
103
|
+
* request does not say which of these it is, so `fetch` lists the four it can be.
|
|
104
|
+
*/
|
|
105
|
+
const REPLACEMENT: ReadonlyMap<TransportShape, string> = new Map([
|
|
106
|
+
[
|
|
107
|
+
'fetch',
|
|
108
|
+
"an action: `await browserClient.<action>(input)` (rpc<Api['actions']> in apps/web/shared/browser-client.ts); a read: `useQuery(<QUERY_REF>, input)` from '@ultimat3/realtime'; a file: `await uploadFile({ file, grant })` from '@ultimat3/storage'; anything else: `await clientTransport({ method: 'GET', url })` from '@ultimat3/core/page'",
|
|
109
|
+
],
|
|
110
|
+
[
|
|
111
|
+
'websocket',
|
|
112
|
+
"`useQuery(<QUERY_REF>, input)` for rows or `useChannel(<CHANNEL_REF>, params, { onEvent })` for events, both from '@ultimat3/realtime' — the page has one socket and it is the framework's",
|
|
113
|
+
],
|
|
114
|
+
[
|
|
115
|
+
'xhr',
|
|
116
|
+
"an upload with progress: `await uploadFile({ file, grant, onProgress })` from '@ultimat3/storage'; anything else: `await clientTransport({ method: 'GET', url })` from '@ultimat3/core/page'",
|
|
117
|
+
],
|
|
118
|
+
[
|
|
119
|
+
'eventsource',
|
|
120
|
+
"`useQuery(<QUERY_REF>, input)` or `useChannel(<CHANNEL_REF>, params, { onEvent })` from '@ultimat3/realtime' — pushed frames arrive on the page's one socket",
|
|
121
|
+
],
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
export interface FindingContext {
|
|
125
|
+
/** Who may hold `shape` instead — a file in a repo, the framework's function in an app. */
|
|
126
|
+
readonly holder: (shape: TransportShape) => string | undefined;
|
|
127
|
+
/** The command that re-reads the tree: the gate's step in an app, the script in this repo. */
|
|
128
|
+
readonly rerun: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `file:line` for a message: the path is the scanned tree's, so it is escaped before it is quoted. */
|
|
132
|
+
const siteOf = (site: { readonly file: string; readonly line: number }): string =>
|
|
133
|
+
`${commentSafe(site.file)}:${site.line}`;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One runnable line, the edit behind a `#` — the shape `app-boundaries.ts` gives every fix. The
|
|
137
|
+
* command re-reads the tree; the comment is the edit, because only the finding knows the line.
|
|
138
|
+
*/
|
|
139
|
+
export function bypassFinding(bypass: Bypass, context: FindingContext): Finding {
|
|
140
|
+
const holder = context.holder(bypass.shape);
|
|
141
|
+
const site = siteOf(bypass);
|
|
142
|
+
return {
|
|
143
|
+
code: 'X_BROWSER_TRANSPORT_BYPASS',
|
|
144
|
+
at: `${bypass.file}:${bypass.line}`,
|
|
145
|
+
cause: `${site} calls ${bypass.spelled} in browser-reachable code; ${holder === undefined ? 'no module may' : `only ${holder} may`}, so this request skips the page's record store, idempotency header and principal fence`,
|
|
146
|
+
fix: `${context.rerun} # after replacing ${bypass.spelled} at ${site} with ${REPLACEMENT.get(bypass.shape) ?? ''}`,
|
|
147
|
+
docs: ERROR_DOCS_URL,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function barrelFinding(site: ServerBarrel, context: FindingContext): Finding {
|
|
152
|
+
const at = siteOf(site);
|
|
153
|
+
return {
|
|
154
|
+
code: 'X_BROWSER_SERVER_BARREL',
|
|
155
|
+
at: `${site.file}:${site.line}`,
|
|
156
|
+
cause: `${at} imports ${site.barrel} in browser-reachable code; that barrel is the package's server half, and ${site.entry} is the entry it publishes for a browser`,
|
|
157
|
+
fix: `${context.rerun} # after changing the import at ${at} from '${site.barrel}' to '${site.entry}'`,
|
|
158
|
+
docs: ERROR_DOCS_URL,
|
|
159
|
+
};
|
|
160
|
+
}
|