@ultimat3/cli 22.15.0 → 24.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 +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -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 +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- 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/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -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/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- 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 +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -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/naming.ts +28 -0
- 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 +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- 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 +144 -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-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- 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 +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- 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 +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- 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/verify-errors.ts +0 -49
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The `policy` step's provenance question: every permission the app's OWN actions, queries and
|
|
2
|
+
// routes require is one the app ITSELF declares. `defineAdmin()` declares `<entity>:read|write|
|
|
3
|
+
// delete` for every mounted entity, so an app rule naming one passed in the web process and threw
|
|
4
|
+
// X_PERMISSION_UNKNOWN in any process that never mounted the admin — with nothing saying so.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path API, and `relative` renders a frame's absolute path against the app root.
|
|
7
|
+
import { relative } from 'node:path';
|
|
8
|
+
import { listActions } from '@ultimat3/action';
|
|
9
|
+
import { type Policy, permissionDeclarationSites, policyPermissions } from '@ultimat3/policy';
|
|
10
|
+
import { listQueries } from '@ultimat3/query';
|
|
11
|
+
import { routeEntries } from '@ultimat3/render';
|
|
12
|
+
import { frameFile, siteFile } from './app-permissions-site';
|
|
13
|
+
import type { Finding } from './output';
|
|
14
|
+
|
|
15
|
+
/** One permission one app declaration requires. */
|
|
16
|
+
export interface AppRule {
|
|
17
|
+
readonly permission: string;
|
|
18
|
+
/** `action createPost`, `query postList`, or the route file. */
|
|
19
|
+
readonly by: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** An app rule whose permission no app module declares, and the file that did declare it. */
|
|
23
|
+
export interface BorrowedPermission extends AppRule {
|
|
24
|
+
/**
|
|
25
|
+
* Root-relative paths of EVERY declaring module (`node_modules/…` or `../…`), distinct and
|
|
26
|
+
* sorted: naming only the first told the reader one package declared it when two did.
|
|
27
|
+
*/
|
|
28
|
+
readonly from: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* EVERY permission in each tree, composites included — unlike the grant question, which judges
|
|
33
|
+
* only a bare `can()`: an `or()` branch is still a `can()` that asserted its name, so it needs the
|
|
34
|
+
* app's own declaration as much as a bare one does. A screen the admin MOUNTS is not here: those
|
|
35
|
+
* are the package's rules, and declaring their names is the package's job.
|
|
36
|
+
*/
|
|
37
|
+
export function appRules(): readonly AppRule[] {
|
|
38
|
+
const named = (by: string, policy: Policy<unknown, unknown>): readonly AppRule[] =>
|
|
39
|
+
policyPermissions(policy).map((permission) => ({ permission, by }));
|
|
40
|
+
return [
|
|
41
|
+
...listActions().flatMap((target) => named(`action ${target.name}`, target.policy)),
|
|
42
|
+
...listQueries().flatMap((target) => named(`query ${target.name}`, target.policy)),
|
|
43
|
+
...routeEntries().flatMap((entry) => {
|
|
44
|
+
const permission = entry.config.policy?.permission;
|
|
45
|
+
return permission === undefined ? [] : [{ permission, by: entry.file }];
|
|
46
|
+
}),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An app module's root-relative path, or `undefined` for a dependency's or an unreadable frame. */
|
|
51
|
+
const appFile = (root: string, site: string): string | undefined => {
|
|
52
|
+
const file = siteFile(root, site);
|
|
53
|
+
return file === undefined || file.split('/').includes('node_modules') ? undefined : file;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Provenance by the declaring module's PATH, which is where the call ran — never by the name's
|
|
58
|
+
* shape: `posts:read` is the same string whether `defineAdmin()` or the app declared it. A name
|
|
59
|
+
* with any unreadable site is one whose origin nobody can state, so it is not judged — and that
|
|
60
|
+
* covers an UNKNOWN name too, which has no site at all: it is X_PERMISSION_UNKNOWN's, asked first,
|
|
61
|
+
* and a second finding would be a second instruction for one fault.
|
|
62
|
+
*/
|
|
63
|
+
export function borrowedPermissions(
|
|
64
|
+
root: string,
|
|
65
|
+
rules: readonly AppRule[] = appRules(),
|
|
66
|
+
declaredAt: Readonly<Record<string, readonly string[]>> = permissionDeclarationSites(),
|
|
67
|
+
): readonly BorrowedPermission[] {
|
|
68
|
+
const found = new Map<string, BorrowedPermission>();
|
|
69
|
+
for (const rule of rules) {
|
|
70
|
+
const sites = Object.hasOwn(declaredAt, rule.permission) ? declaredAt[rule.permission] : [];
|
|
71
|
+
const files = (sites ?? []).map(frameFile);
|
|
72
|
+
if (files.length === 0 || files.some((file) => file === undefined)) continue;
|
|
73
|
+
if ((sites ?? []).some((site) => appFile(root, site) !== undefined)) continue;
|
|
74
|
+
const from = [
|
|
75
|
+
...new Set(
|
|
76
|
+
files.flatMap((file) =>
|
|
77
|
+
file === undefined ? [] : [relative(root, file).replaceAll('\\', '/')],
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
].sort();
|
|
81
|
+
found.set(`${rule.permission}\u0000${rule.by}`, { ...rule, from });
|
|
82
|
+
}
|
|
83
|
+
return [...found.values()].sort((a, b) =>
|
|
84
|
+
a.permission === b.permission
|
|
85
|
+
? a.by.localeCompare(b.by)
|
|
86
|
+
: a.permission.localeCompare(b.permission),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The app file its permissions are declared in — where the missing name is added. Read off the
|
|
92
|
+
* recorded sites, never a scaffold path; none under the root is `undefined`, never a guess.
|
|
93
|
+
*/
|
|
94
|
+
export function permissionsFile(root: string): string | undefined {
|
|
95
|
+
for (const sites of Object.values(permissionDeclarationSites())) {
|
|
96
|
+
for (const site of sites) {
|
|
97
|
+
const file = appFile(root, site);
|
|
98
|
+
if (file !== undefined) return file;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A TS string literal for the name — quoted the house way unless the name itself needs escaping. */
|
|
105
|
+
const literal = (name: string): string =>
|
|
106
|
+
/^[\w:.*-]+$/.test(name) ? `'${name}'` : JSON.stringify(name);
|
|
107
|
+
|
|
108
|
+
export function borrowedFinding(
|
|
109
|
+
entry: BorrowedPermission,
|
|
110
|
+
declaredIn: string | undefined,
|
|
111
|
+
): Finding {
|
|
112
|
+
const [only, ...more] = entry.from;
|
|
113
|
+
const declarers =
|
|
114
|
+
more.length === 0 ? `only ${only} declares it` : `only ${entry.from.join(', ')} declare it`;
|
|
115
|
+
const where =
|
|
116
|
+
declaredIn === undefined
|
|
117
|
+
? 'paste this call into an app module'
|
|
118
|
+
: `paste this call into ${declaredIn} (definePermissions() merges, so a second call is fine)`;
|
|
119
|
+
const finding: Finding = {
|
|
120
|
+
code: 'X_PERMISSION_BORROWED',
|
|
121
|
+
cause:
|
|
122
|
+
`${entry.by} requires ${literal(entry.permission)}, and ${declarers} — no app module does, ` +
|
|
123
|
+
"so the rule holds only in a process that ran that package's declaration (defineAdmin()) " +
|
|
124
|
+
`first; ${where}, then x verify --only policy`,
|
|
125
|
+
// A call to paste, never prose: `add …` read as a command answered `add: command not found`.
|
|
126
|
+
fix: `definePermissions([${literal(entry.permission)}])`,
|
|
127
|
+
};
|
|
128
|
+
return declaredIn === undefined ? finding : { ...finding, at: declaredIn };
|
|
129
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Single responsibility: a declaration site — a stack FRAME `@ultimat3/policy` recorded for a
|
|
2
|
+
// `defineRoles()` or `definePermissions()` call — reduced to the file it names, absolute or
|
|
3
|
+
// app-root-relative. Both `policy` step questions key their findings by it.
|
|
4
|
+
|
|
5
|
+
// why: Bun ships no path API, and `relative` is what turns a stack frame's absolute path into
|
|
6
|
+
// the app-root-relative one every finding is keyed by.
|
|
7
|
+
import { relative } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/** The absolute source path a frame (`at foo (/abs/path.ts:3:1)`) names, or `undefined`. */
|
|
10
|
+
export function frameFile(site: string): string | undefined {
|
|
11
|
+
return /\(?(\/[^():]+\.[cm]?tsx?)(?::\d+)?(?::\d+)?\)?/.exec(site)?.[1];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The frame's file relative to the app root, or `undefined` outside it: a locator nobody can act on
|
|
16
|
+
* is worse than none. The frame is the only place the declaring module's name exists by the time
|
|
17
|
+
* the declaration runs.
|
|
18
|
+
*/
|
|
19
|
+
export function siteFile(root: string, site: string): string | undefined {
|
|
20
|
+
const absolute = frameFile(site);
|
|
21
|
+
if (absolute === undefined) return undefined;
|
|
22
|
+
const rel = relative(root, absolute).replaceAll('\\', '/');
|
|
23
|
+
return rel.startsWith('..') || rel.length === 0 ? undefined : rel;
|
|
24
|
+
}
|
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
|
+
}
|