@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,59 @@
|
|
|
1
|
+
// The workspace edges `x g` just wrote, declared in the manifest of the workspace each file landed
|
|
2
|
+
// in. `package-shape` refuses an import its owner does not declare (X_WORKSPACE_DEP_UNDECLARED), and
|
|
3
|
+
// a generator that emits one hands the author a red gate for the generator's own output — the edit
|
|
4
|
+
// is mechanical, so the generator makes it. Shipped source only, the checker's own scope.
|
|
5
|
+
|
|
6
|
+
import { containedPath } from './generate-write';
|
|
7
|
+
import { isGenerated, isTest } from './source-files';
|
|
8
|
+
import { withDependency } from './workspace-dep-edit';
|
|
9
|
+
import { importedPackages, scanWorkspaces, type WorkspaceNode } from './workspace-graph';
|
|
10
|
+
|
|
11
|
+
const SOURCE = /\.tsx?$/;
|
|
12
|
+
|
|
13
|
+
/** Per owning workspace, the sibling workspaces its new files import and it does not declare. */
|
|
14
|
+
async function missingEdges(
|
|
15
|
+
root: string,
|
|
16
|
+
written: readonly string[],
|
|
17
|
+
): Promise<Map<WorkspaceNode, Map<string, string>>> {
|
|
18
|
+
const missing = new Map<WorkspaceNode, Map<string, string>>();
|
|
19
|
+
const sources = written.filter(
|
|
20
|
+
(path) => SOURCE.test(path) && !isTest(path) && !isGenerated(path),
|
|
21
|
+
);
|
|
22
|
+
if (sources.length === 0) return missing;
|
|
23
|
+
const { nodes } = await scanWorkspaces(root);
|
|
24
|
+
const byName = new Map(nodes.map((node) => [node.name, node]));
|
|
25
|
+
// Deepest directory first, as the checker reads it: a nested workspace's files are its own.
|
|
26
|
+
const owners = [...nodes].sort((left, right) => right.dir.length - left.dir.length);
|
|
27
|
+
for (const path of sources) {
|
|
28
|
+
const owner = owners.find((node) => path.startsWith(`${node.dir}/`));
|
|
29
|
+
if (owner === undefined) continue;
|
|
30
|
+
const text = await Bun.file(containedPath(root, path)).text();
|
|
31
|
+
for (const name of importedPackages(text, path)) {
|
|
32
|
+
const target = byName.get(name);
|
|
33
|
+
if (target === undefined || target === owner || owner.dependencies.includes(name)) continue;
|
|
34
|
+
const edges = missing.get(owner) ?? new Map<string, string>();
|
|
35
|
+
edges.set(name, target.version ?? '0.0.0');
|
|
36
|
+
missing.set(owner, edges);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return missing;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Writes each owning manifest once, holding every new edge; answers the manifests it rewrote. */
|
|
43
|
+
export async function declareGeneratedImports(
|
|
44
|
+
root: string,
|
|
45
|
+
written: readonly string[],
|
|
46
|
+
): Promise<readonly string[]> {
|
|
47
|
+
const edited: string[] = [];
|
|
48
|
+
for (const [owner, edges] of await missingEdges(root, written)) {
|
|
49
|
+
const path = `${owner.dir}/package.json`;
|
|
50
|
+
const file = Bun.file(containedPath(root, path));
|
|
51
|
+
const before = await file.text();
|
|
52
|
+
let after = before;
|
|
53
|
+
for (const [name, version] of edges) after = withDependency(after, name, version) ?? after;
|
|
54
|
+
if (after === before) continue;
|
|
55
|
+
await Bun.write(containedPath(root, path), after);
|
|
56
|
+
edited.push(path);
|
|
57
|
+
}
|
|
58
|
+
return edited;
|
|
59
|
+
}
|
package/src/gh-target.ts
CHANGED
|
@@ -76,18 +76,23 @@ const PR_VIEW = t.object({ number: t.number });
|
|
|
76
76
|
* The pull request for the current branch. gh exits non-zero when there is none, with a message
|
|
77
77
|
* that names the branch — so the refusal keeps gh's own sentence and adds the remedy gh has no
|
|
78
78
|
* opinion about: name the number, or open the PR.
|
|
79
|
+
*
|
|
80
|
+
* No `--repo`: gh refuses `pr view --repo <slug>` without a number, url or branch before any
|
|
81
|
+
* network call, so that argv could never succeed. The checkout-scoped form resolves the branch's
|
|
82
|
+
* tracking remote itself — the same resolution `gh repo view` gave `resolveRepo`. `repo` names the
|
|
83
|
+
* pull request in the refusal only.
|
|
79
84
|
*/
|
|
80
85
|
export async function resolvePrNumber(host: GhHost, repo: GhRepo): Promise<number> {
|
|
81
86
|
try {
|
|
82
|
-
const viewed = await ghJson(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
{ label: 'gh pr view', fix: 'x pr review --pr 241 --json' },
|
|
87
|
-
);
|
|
87
|
+
const viewed = await ghJson(host, ['pr', 'view', '--json', 'number'], PR_VIEW, {
|
|
88
|
+
label: 'gh pr view',
|
|
89
|
+
fix: 'x pr review --pr 241 --json',
|
|
90
|
+
});
|
|
88
91
|
return viewed.number;
|
|
89
92
|
} catch (error) {
|
|
90
|
-
if (error instanceof GhFailedError)
|
|
93
|
+
if (error instanceof GhFailedError) {
|
|
94
|
+
throw new PrNotFoundError({ detail: `${repo.slug}: ${error.cause}` });
|
|
95
|
+
}
|
|
91
96
|
throw error;
|
|
92
97
|
}
|
|
93
98
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// One read of the app per gate run, shared by every guard. Every guard `SHIPPED_GUARD_NAMES` lists
|
|
2
|
+
// (`templates/scaffold-guards.ts`), each walking the tree and re-reading every stylesheet, is one
|
|
3
|
+
// walk per guard for one answer, and two guards compiling the same module is Sass run twice — so
|
|
4
|
+
// the runner hands each `check` these, memoised for the run.
|
|
5
|
+
|
|
6
|
+
// why: `join` builds the host-separator path a glob entry is read at; Bun ships no path API.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export interface GuardFile {
|
|
10
|
+
/** App-root-relative POSIX path, so a finding names the file an author opens. */
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly text: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A stylesheet as the build compiles it. */
|
|
16
|
+
export interface GuardStylesheet {
|
|
17
|
+
readonly css: string;
|
|
18
|
+
/** `hero` → its scoped name. Empty for a sheet that is not a `.module.scss`. */
|
|
19
|
+
readonly classes: Readonly<Record<string, string>>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GuardSources {
|
|
23
|
+
/**
|
|
24
|
+
* Every file under the app root matching `glob`, sorted. Dependencies, build output and caches
|
|
25
|
+
* are never the app, so they are never returned. Asked twice, walked once.
|
|
26
|
+
*/
|
|
27
|
+
files(glob: string): Promise<readonly GuardFile[]>;
|
|
28
|
+
/**
|
|
29
|
+
* The stylesheet at an app-root-relative path, compiled by the compiler the build uses.
|
|
30
|
+
* `undefined` when the file is absent or does not compile: that refusal is the build's
|
|
31
|
+
* (`X_PRERENDER_FAILED`, with the Sass error), never a guard's to repeat.
|
|
32
|
+
*/
|
|
33
|
+
compiled(path: string): Promise<GuardStylesheet | undefined>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Path segments that are never the app's own source. */
|
|
37
|
+
const NOT_SOURCE = /(?:^|\/)(?:node_modules|dist|\.x|\.git)\//;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `seed` is text the gate step ALREADY read — the `boundaries` step reads every surface file for
|
|
41
|
+
* its own import rules before any guard runs, and a guard that walks `apps/**` would read each of
|
|
42
|
+
* them a second time. Seeded, a path is read at most once per step; anything the seed does not
|
|
43
|
+
* hold (a stylesheet, a test, a package) is read here on first ask, as before.
|
|
44
|
+
*/
|
|
45
|
+
export function guardSources(
|
|
46
|
+
root: string,
|
|
47
|
+
seed: readonly { readonly path: string; readonly source: string }[] = [],
|
|
48
|
+
): GuardSources {
|
|
49
|
+
const texts = new Map<string, Promise<string>>();
|
|
50
|
+
for (const file of seed) texts.set(file.path, Promise.resolve(file.source));
|
|
51
|
+
const walks = new Map<string, Promise<readonly GuardFile[]>>();
|
|
52
|
+
const sheets = new Map<string, Promise<GuardStylesheet | undefined>>();
|
|
53
|
+
|
|
54
|
+
const textOf = (path: string): Promise<string> => {
|
|
55
|
+
let text = texts.get(path);
|
|
56
|
+
if (text === undefined) {
|
|
57
|
+
text = Bun.file(join(root, path)).text();
|
|
58
|
+
texts.set(path, text);
|
|
59
|
+
}
|
|
60
|
+
return text;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const walk = async (glob: string): Promise<readonly GuardFile[]> => {
|
|
64
|
+
const paths: string[] = [];
|
|
65
|
+
for await (const entry of new Bun.Glob(glob).scan({ cwd: root, absolute: false })) {
|
|
66
|
+
const path = entry.split('\\').join('/');
|
|
67
|
+
if (!NOT_SOURCE.test(path)) paths.push(path);
|
|
68
|
+
}
|
|
69
|
+
paths.sort();
|
|
70
|
+
return Promise.all(paths.map(async (path) => ({ path, text: await textOf(path) })));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const compile = async (path: string): Promise<GuardStylesheet | undefined> => {
|
|
74
|
+
try {
|
|
75
|
+
const source = await textOf(path);
|
|
76
|
+
// Imported here, not at the top: Sass loads on the first compile, and a gate run whose
|
|
77
|
+
// guards compile nothing must not pay for the compiler (`sass-lazy.test.ts`).
|
|
78
|
+
const { compileStylesheet } = await import('@ultimat3/render/server');
|
|
79
|
+
const { css, classes } = compileStylesheet(join(root, path), source);
|
|
80
|
+
return { css, classes };
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
files(glob) {
|
|
88
|
+
let found = walks.get(glob);
|
|
89
|
+
if (found === undefined) {
|
|
90
|
+
found = walk(glob);
|
|
91
|
+
walks.set(glob, found);
|
|
92
|
+
}
|
|
93
|
+
return found;
|
|
94
|
+
},
|
|
95
|
+
compiled(path) {
|
|
96
|
+
let sheet = sheets.get(path);
|
|
97
|
+
if (sheet === undefined) {
|
|
98
|
+
sheet = compile(path);
|
|
99
|
+
sheets.set(path, sheet);
|
|
100
|
+
}
|
|
101
|
+
return sheet;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/guards.ts
CHANGED
|
@@ -11,8 +11,8 @@ import { join } from 'node:path';
|
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
12
|
import { ERROR_DOCS_URL, renderCauseValue, renderThrowable } from '@ultimat3/core';
|
|
13
13
|
import { fixProblem } from './error-contract';
|
|
14
|
+
import { type GuardSources, guardSources } from './guard-sources';
|
|
14
15
|
import type { Finding } from './output';
|
|
15
|
-
import type { HostCheck } from './verify-step';
|
|
16
16
|
|
|
17
17
|
/** The directory IS the registration. An app-side list is a list an app can forget to add to. */
|
|
18
18
|
export const GUARD_DIR = 'guards';
|
|
@@ -24,8 +24,13 @@ export interface Guard {
|
|
|
24
24
|
* The rule, over the app root. Returns findings — it never prints, never decides an exit code
|
|
25
25
|
* and never throws for a normal result, because `--json`, the step table and the exit code are
|
|
26
26
|
* all projections of what it returns (axiom 2).
|
|
27
|
+
*
|
|
28
|
+
* `sources` is the run's ONE read of the app (`guard-sources.ts`): a guard that asks it for
|
|
29
|
+
* files or a compiled stylesheet shares the walk, the read and the compile with every other
|
|
30
|
+
* guard in the directory. A guard that reads something else — a migration, a lockfile — still
|
|
31
|
+
* may, from `root`.
|
|
27
32
|
*/
|
|
28
|
-
check(root: string): Promise<readonly Finding[]> | readonly Finding[];
|
|
33
|
+
check(root: string, sources: GuardSources): Promise<readonly Finding[]> | readonly Finding[];
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -129,7 +134,11 @@ const findingInvalid = (path: string, cause: string): Finding => ({
|
|
|
129
134
|
const messageOf = (error: unknown): string => renderThrowable(error);
|
|
130
135
|
|
|
131
136
|
/** One guard: import it, run it, and hold what it returns to the contract. Never throws. */
|
|
132
|
-
async function runGuard(
|
|
137
|
+
async function runGuard(
|
|
138
|
+
root: string,
|
|
139
|
+
path: string,
|
|
140
|
+
sources: GuardSources,
|
|
141
|
+
): Promise<readonly Finding[]> {
|
|
133
142
|
let loaded: unknown;
|
|
134
143
|
try {
|
|
135
144
|
loaded = await import(pathToFileURL(join(root, path)).href);
|
|
@@ -149,7 +158,7 @@ async function runGuard(root: string, path: string): Promise<readonly Finding[]>
|
|
|
149
158
|
}
|
|
150
159
|
let returned: unknown;
|
|
151
160
|
try {
|
|
152
|
-
returned = await exported.check(root);
|
|
161
|
+
returned = await exported.check(root, sources);
|
|
153
162
|
} catch (error) {
|
|
154
163
|
return [failed(path, `${path} ("${exported.summary}") threw: ${messageOf(error)}`)];
|
|
155
164
|
}
|
|
@@ -173,14 +182,21 @@ async function runGuard(root: string, path: string): Promise<readonly Finding[]>
|
|
|
173
182
|
}
|
|
174
183
|
|
|
175
184
|
/**
|
|
176
|
-
* Every guard this app declares, as findings the step it rides on adds to its own.
|
|
185
|
+
* Every guard this app declares, as findings the step it rides on adds to its own. Shaped as a
|
|
177
186
|
* `HostCheck` because that is exactly the seam's shape — a rule the repo enforces on itself,
|
|
178
187
|
* contributed to a step that already exists. A guard can never add, remove, reorder or skip a
|
|
179
188
|
* step, which is what keeps "green" meaning one thing (axiom 5) while the app still gets to make
|
|
180
189
|
* its own convention a build error.
|
|
181
190
|
*/
|
|
182
|
-
export const guardFindings
|
|
191
|
+
export const guardFindings = async (
|
|
192
|
+
root: string,
|
|
193
|
+
/** Files the step already read (`guardSources`' seed), so no guard reads one a second time. */
|
|
194
|
+
seed: readonly { readonly path: string; readonly source: string }[] = [],
|
|
195
|
+
): Promise<readonly Finding[]> => {
|
|
183
196
|
const findings: Finding[] = [];
|
|
184
|
-
|
|
197
|
+
const sources = guardSources(root, seed);
|
|
198
|
+
for (const path of await guardPaths(root)) {
|
|
199
|
+
findings.push(...(await runGuard(root, path, sources)));
|
|
200
|
+
}
|
|
185
201
|
return findings;
|
|
186
202
|
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// `x g entity` / `x g resource` hand the entity they wrote to the app's typed database handle. The
|
|
2
|
+
// generated `repo.ts` reads `db.<table>`, and a table is on the handle only when its entity is in
|
|
3
|
+
// the set `packages/db/src/client.ts` passes to `database()` — an edit nothing made, so the repo
|
|
4
|
+
// did not compile. Performed here; where the file is not the scaffold's shape, the finding names
|
|
5
|
+
// the two lines.
|
|
6
|
+
|
|
7
|
+
import { ERROR_DOCS_URL, stripComments } from '@ultimat3/core';
|
|
8
|
+
import { containedPath } from './generate-write';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
import { holdsKey, withKeyedLine, withSortedImport } from './source-edit';
|
|
11
|
+
import type { GeneratedFile } from './templates/naming';
|
|
12
|
+
import { plural } from './templates/naming';
|
|
13
|
+
import type { HandleEntry } from './templates/scaffold-db-client';
|
|
14
|
+
import {
|
|
15
|
+
dbClientSource,
|
|
16
|
+
HANDLE_FILE,
|
|
17
|
+
handleEntryLine,
|
|
18
|
+
handleImportLine,
|
|
19
|
+
PLACEHOLDER_DB_MODULE,
|
|
20
|
+
webModuleOf,
|
|
21
|
+
} from './templates/scaffold-db-client';
|
|
22
|
+
import type { PlannedEdit } from './workspace-dep-edit';
|
|
23
|
+
import { planWorkspaceDependency, readWorkspace } from './workspace-dep-edit';
|
|
24
|
+
|
|
25
|
+
/** The two workspaces the handle joins: it lives in one and imports entities from the other. */
|
|
26
|
+
export const DB_WORKSPACE = 'packages/db';
|
|
27
|
+
const WEB_WORKSPACE = 'apps/web';
|
|
28
|
+
const INDEX_FILE = `${DB_WORKSPACE}/src/index.ts`;
|
|
29
|
+
|
|
30
|
+
const ENTITY_PATH = /^apps\/web\/(?<rest>[^/]+\/[^/]+)\/entity\.ts$/;
|
|
31
|
+
const ENTITY_EXPORT = /\bexport const ([A-Za-z_$][\w$]*) = entity\(/;
|
|
32
|
+
const IDENTIFIER = '[A-Za-z_$][\\w$]*';
|
|
33
|
+
|
|
34
|
+
/** The `@<app>/db` a generated `repo.ts` imports the handle from; absent when the app names none. */
|
|
35
|
+
export const resolveDbModule = async (root: string): Promise<string | undefined> =>
|
|
36
|
+
(await readWorkspace(root, DB_WORKSPACE))?.name;
|
|
37
|
+
|
|
38
|
+
/** The finding for an entity the handle does not serve, carrying the edit that makes it. */
|
|
39
|
+
export const handleUnregisteredFinding = (entry: HandleEntry, reason: string): Finding => ({
|
|
40
|
+
code: 'X_DB_HANDLE_UNREGISTERED',
|
|
41
|
+
cause: `${HANDLE_FILE} ${reason}, so db.${entry.key} does not exist and the generated repo.ts does not compile`,
|
|
42
|
+
fix: `add ${handleImportLine(entry)} and the line "${handleEntryLine(entry).trim()}" to the entity set passed to database() in ${HANDLE_FILE}`,
|
|
43
|
+
docs: ERROR_DOCS_URL,
|
|
44
|
+
at: HANDLE_FILE,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** The object literal `database(<name>, …)` is called with: where it opens and where it closes. */
|
|
48
|
+
function entitySetOf(
|
|
49
|
+
source: string,
|
|
50
|
+
): { readonly open: number; readonly close: number } | undefined {
|
|
51
|
+
const call = new RegExp(`\\bdatabase\\(\\s*(${IDENTIFIER})\\s*[,)]`).exec(source);
|
|
52
|
+
if (call === null) return undefined;
|
|
53
|
+
const declared = new RegExp(`\\bconst ${call[1]}\\s*=\\s*\\{`).exec(source);
|
|
54
|
+
if (declared === null) return undefined;
|
|
55
|
+
const open = declared.index + declared[0].length;
|
|
56
|
+
const close = source.indexOf('}', open);
|
|
57
|
+
return close === -1 ? undefined : { open, close };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `source` with each entry imported and listed. An entry whose key the set already holds is left
|
|
62
|
+
* alone; a file with no `const <set> = { … }` handed to `database()` is returned untouched with
|
|
63
|
+
* every entry in `missing`.
|
|
64
|
+
*/
|
|
65
|
+
export function insertHandleEntries(
|
|
66
|
+
source: string,
|
|
67
|
+
entries: readonly HandleEntry[],
|
|
68
|
+
): { readonly source: string; readonly missing: readonly HandleEntry[] } {
|
|
69
|
+
let next = source;
|
|
70
|
+
const missing: HandleEntry[] = [];
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const set = entitySetOf(next);
|
|
73
|
+
if (set === undefined) {
|
|
74
|
+
missing.push(entry);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const body = next.slice(set.open, set.close);
|
|
78
|
+
if (holdsKey(stripComments(body), entry.key)) continue;
|
|
79
|
+
const listed = withKeyedLine(body, entry.key, handleEntryLine(entry), ' ');
|
|
80
|
+
next = withSortedImport(
|
|
81
|
+
`${next.slice(0, set.open)}${listed}${next.slice(set.close)}`,
|
|
82
|
+
handleImportLine(entry),
|
|
83
|
+
entry.specifier,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return { source: next, missing };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The entry an `entity.ts` implies, named by what the file exports; `undefined` for any other path. */
|
|
90
|
+
function entryOf(path: string, source: string, webModule: string): HandleEntry | undefined {
|
|
91
|
+
const rest = ENTITY_PATH.exec(path)?.groups?.['rest'];
|
|
92
|
+
const binding = ENTITY_EXPORT.exec(source)?.[1];
|
|
93
|
+
if (rest === undefined || binding === undefined) return undefined;
|
|
94
|
+
return { key: plural(binding), binding, specifier: `${webModule}/${rest}/entity` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The same registration over a file LIST, for a caller that composes generators without a disk —
|
|
99
|
+
* the scaffold fixture runs every generator on top of `x new` and compiles the result, and a
|
|
100
|
+
* handle that listed only the example entity would fail that compile on every other repo.
|
|
101
|
+
*/
|
|
102
|
+
export function withHandleEntries(
|
|
103
|
+
files: readonly GeneratedFile[],
|
|
104
|
+
dbModule: string,
|
|
105
|
+
): readonly GeneratedFile[] {
|
|
106
|
+
const entries = files.flatMap((file) => {
|
|
107
|
+
if (typeof file.contents !== 'string') return [];
|
|
108
|
+
const entry = entryOf(file.path, file.contents, webModuleOf(dbModule));
|
|
109
|
+
return entry === undefined ? [] : [entry];
|
|
110
|
+
});
|
|
111
|
+
return files.map((file) =>
|
|
112
|
+
file.path === HANDLE_FILE && typeof file.contents === 'string' && file.merge === undefined
|
|
113
|
+
? { ...file, contents: insertHandleEntries(file.contents, entries).source }
|
|
114
|
+
: file,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The entries the written files imply: one per `entity.ts` this run put on disk. */
|
|
119
|
+
async function entriesFor(
|
|
120
|
+
root: string,
|
|
121
|
+
written: readonly string[],
|
|
122
|
+
webModule: string,
|
|
123
|
+
): Promise<readonly HandleEntry[]> {
|
|
124
|
+
const entries: HandleEntry[] = [];
|
|
125
|
+
for (const path of written) {
|
|
126
|
+
if (!ENTITY_PATH.test(path)) continue;
|
|
127
|
+
const entry = entryOf(path, await Bun.file(containedPath(root, path)).text(), webModule);
|
|
128
|
+
if (entry !== undefined) entries.push(entry);
|
|
129
|
+
}
|
|
130
|
+
return entries;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface HandleRegistration {
|
|
134
|
+
/** App-root-relative paths this rewrote or created. */
|
|
135
|
+
readonly edited: readonly string[];
|
|
136
|
+
readonly findings: readonly Finding[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The handle exists but the package does not export it: the repo's import reaches something else. */
|
|
140
|
+
async function indexFindings(root: string, dbModule: string): Promise<readonly Finding[]> {
|
|
141
|
+
const file = Bun.file(containedPath(root, INDEX_FILE));
|
|
142
|
+
const source = (await file.exists()) ? await file.text() : '';
|
|
143
|
+
if (/from\s+'\.\/client'/.test(stripComments(source))) return [];
|
|
144
|
+
return [
|
|
145
|
+
{
|
|
146
|
+
code: 'X_DB_HANDLE_UNREGISTERED',
|
|
147
|
+
cause: `${INDEX_FILE} does not export the typed handle, so "import { db } from '${dbModule}'" in the generated repo.ts is not database()'s`,
|
|
148
|
+
fix: `add export { db, driver, selectDriver } from './client'; to ${INDEX_FILE} and drop db from its '@ultimat3/db' re-export`,
|
|
149
|
+
docs: ERROR_DOCS_URL,
|
|
150
|
+
at: INDEX_FILE,
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Performs the registration for every entity among `written`: the handle's set and import, then
|
|
157
|
+
* the manifest lines the new edges need. Creates the handle in an app that has none.
|
|
158
|
+
*
|
|
159
|
+
* PLANNED, then written — all of it or none. A handle with no `const <set> = { … }` refuses the
|
|
160
|
+
* entry, and a run that then went on to edit `packages/db/package.json` left a manifest line for
|
|
161
|
+
* an import nobody wrote: `packages/db` depending on the app, behind a finding that said nothing
|
|
162
|
+
* was registered.
|
|
163
|
+
*
|
|
164
|
+
* And the edge from the db package to the app is declared only for the import that needs it. The
|
|
165
|
+
* handle imports each entity from the web workspace, so that edge is real exactly when the handle
|
|
166
|
+
* this run leaves names that workspace; the other one — the app's `repo.ts` importing the handle —
|
|
167
|
+
* is every entity's.
|
|
168
|
+
*/
|
|
169
|
+
export async function registerGeneratedEntities(
|
|
170
|
+
root: string,
|
|
171
|
+
written: readonly string[],
|
|
172
|
+
dbModule: string | undefined,
|
|
173
|
+
): Promise<HandleRegistration> {
|
|
174
|
+
const web = await readWorkspace(root, WEB_WORKSPACE);
|
|
175
|
+
const module = dbModule ?? PLACEHOLDER_DB_MODULE;
|
|
176
|
+
const webModule = web?.name ?? webModuleOf(module);
|
|
177
|
+
const entries = await entriesFor(root, written, webModule);
|
|
178
|
+
if (entries.length === 0) return { edited: [], findings: [] };
|
|
179
|
+
const planned: PlannedEdit[] = [];
|
|
180
|
+
const file = Bun.file(containedPath(root, HANDLE_FILE));
|
|
181
|
+
const before = (await file.exists()) ? await file.text() : undefined;
|
|
182
|
+
const { source, missing } =
|
|
183
|
+
before === undefined
|
|
184
|
+
? { source: dbClientSource(entries), missing: [] }
|
|
185
|
+
: insertHandleEntries(before, entries);
|
|
186
|
+
if (missing.length > 0) {
|
|
187
|
+
// Refused: nothing this registrar would have written is written.
|
|
188
|
+
return {
|
|
189
|
+
edited: [],
|
|
190
|
+
findings: missing.map((entry) =>
|
|
191
|
+
handleUnregisteredFinding(entry, 'passes database() no "const <set> = { … }" to add to'),
|
|
192
|
+
),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (source !== before) planned.push({ path: HANDLE_FILE, contents: source });
|
|
196
|
+
const findings: Finding[] = [];
|
|
197
|
+
if (dbModule === undefined) {
|
|
198
|
+
findings.push({
|
|
199
|
+
code: 'X_DB_HANDLE_UNREGISTERED',
|
|
200
|
+
cause: `${DB_WORKSPACE}/package.json supplies no "name", so the generated repo.ts imports the placeholder ${PLACEHOLDER_DB_MODULE}`,
|
|
201
|
+
fix: `add "name": "@<app>/db" to ${DB_WORKSPACE}/package.json, then re-run this command with --force`,
|
|
202
|
+
docs: ERROR_DOCS_URL,
|
|
203
|
+
at: `${DB_WORKSPACE}/package.json`,
|
|
204
|
+
});
|
|
205
|
+
} else findings.push(...(await indexFindings(root, dbModule)));
|
|
206
|
+
const importsWeb = stripComments(source).includes(`from '${webModule}/`);
|
|
207
|
+
const edges = [
|
|
208
|
+
...(importsWeb ? [[DB_WORKSPACE, WEB_WORKSPACE] as const] : []),
|
|
209
|
+
[WEB_WORKSPACE, DB_WORKSPACE] as const,
|
|
210
|
+
];
|
|
211
|
+
for (const [from, to] of edges) {
|
|
212
|
+
const manifest = await planWorkspaceDependency(root, from, to);
|
|
213
|
+
if (manifest !== undefined) planned.push(manifest);
|
|
214
|
+
}
|
|
215
|
+
for (const edit of planned) await Bun.write(containedPath(root, edit.path), edit.contents);
|
|
216
|
+
return { edited: planned.map((edit) => edit.path), findings };
|
|
217
|
+
}
|
package/src/i18n-audit.ts
CHANGED
|
@@ -28,13 +28,16 @@ import { CATALOG_ROOT, catalogPath } from './templates/locales';
|
|
|
28
28
|
* glob set every text-scanning gate step already shares (`errors`, `filesize`) — a second glob
|
|
29
29
|
* here would mean this command and `x verify` disagree on what "the app's source" is. Test files
|
|
30
30
|
* are excluded: a fixture's `t('fixture.key')` is not a gap the shipped catalogs owe an answer to.
|
|
31
|
+
* So are `guards/`: gate tooling renders nothing, and its fix lines quote `t('…')` as an example.
|
|
31
32
|
* `extractFromFiles` reads by absolute path, so its `file` label is rewritten back to the same
|
|
32
33
|
* root-relative POSIX shape `app-load.ts` uses for every other CLI-reported path.
|
|
33
34
|
*/
|
|
35
|
+
const isGuard = (file: string): boolean => file.startsWith('guards/');
|
|
36
|
+
|
|
34
37
|
export async function scanSource(root: string): Promise<Extraction> {
|
|
35
38
|
const files: string[] = [];
|
|
36
39
|
for await (const file of eachSourceFile(root)) {
|
|
37
|
-
if (!isTest(file)) files.push(file);
|
|
40
|
+
if (!isTest(file) && !isGuard(file)) files.push(file);
|
|
38
41
|
}
|
|
39
42
|
const extraction = await extractFromFiles(files.map((file) => join(root, file)));
|
|
40
43
|
const toRelative = (file: string): string => relative(root, file).split(sep).join('/');
|
package/src/i18n-registration.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
pluralVariantsOf,
|
|
19
19
|
registeredLocales,
|
|
20
20
|
} from '@ultimat3/i18n';
|
|
21
|
+
import { mountedAdminKeys } from './admin-catalog-keys';
|
|
21
22
|
import { loadApp } from './app-load';
|
|
22
23
|
import type { DuplicateProbe } from './duplicate-packages';
|
|
23
24
|
import { duplicateCause, duplicateFinding, findDuplicateInstalls } from './duplicate-packages';
|
|
@@ -50,6 +51,8 @@ export interface RegistrationInput {
|
|
|
50
51
|
readonly load?: AppLoader;
|
|
51
52
|
/** The duplicate-install probe; `findDuplicateInstalls` is the production value. */
|
|
52
53
|
readonly duplicates?: DuplicateProbe;
|
|
54
|
+
/** Keys a mounted admin derives, read after the load; `mountedAdminKeys` is the production value. */
|
|
55
|
+
readonly adminKeys?: () => Promise<readonly string[]>;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
export interface RegistrationReport {
|
|
@@ -126,6 +129,18 @@ export async function checkRegistration(input: RegistrationInput): Promise<Regis
|
|
|
126
129
|
}
|
|
127
130
|
}
|
|
128
131
|
|
|
132
|
+
// A key `defineAdmin` derives is spelled in no source file, so the source audit cannot see it.
|
|
133
|
+
// Judged against the REGISTRY — the framework's base layer answers `admin.group.data` in `en` —
|
|
134
|
+
// and only for a locale that registered: an unregistered one is already a finding above.
|
|
135
|
+
const unregisteredSet = new Set(gaps.map((gap) => gap.locale));
|
|
136
|
+
const adminKeys = await (input.adminKeys ?? mountedAdminKeys)();
|
|
137
|
+
for (const locale of Object.keys(input.catalogs)
|
|
138
|
+
.filter((tag) => !unregisteredSet.has(tag))
|
|
139
|
+
.sort()) {
|
|
140
|
+
const missing = unanswered(catalogFor(locale), adminKeys);
|
|
141
|
+
if (missing.length > 0) findings.push(adminKeysFinding(locale, missing));
|
|
142
|
+
}
|
|
143
|
+
|
|
129
144
|
return {
|
|
130
145
|
ok: findings.length === 0,
|
|
131
146
|
// The load's own findings ride along ONLY when something is unregistered, and that condition is
|
|
@@ -140,6 +155,27 @@ export async function checkRegistration(input: RegistrationInput): Promise<Regis
|
|
|
140
155
|
};
|
|
141
156
|
}
|
|
142
157
|
|
|
158
|
+
/** Keys the catalog does not define, or defines with a placeholder. Labels are never plurals. */
|
|
159
|
+
const unanswered = (catalog: Catalog, keys: readonly string[]): readonly string[] =>
|
|
160
|
+
keys.filter((key) => !Object.hasOwn(catalog, key) || isLoudMiss(catalog[key]));
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `X_CATALOG_MISSING_KEYS` — the same fact, a catalog without keys it is asked for — with the
|
|
164
|
+
* cause naming who asks and a fix that is an EDIT: `x i18n sync`, the code's own fix, seeds the
|
|
165
|
+
* keys SOURCE uses and cannot know these.
|
|
166
|
+
*/
|
|
167
|
+
function adminKeysFinding(locale: Locale, missing: readonly string[]): Finding {
|
|
168
|
+
const path = catalogPath(locale);
|
|
169
|
+
const shown = missing.slice(0, 12).map((key) => `"${key}"`);
|
|
170
|
+
const more = missing.length > shown.length ? `, +${missing.length - shown.length} more` : '';
|
|
171
|
+
return {
|
|
172
|
+
...findingFrom(catalogMissingKeys(locale, missing)),
|
|
173
|
+
cause: `${path} is missing ${missing.length} key(s) the mounted admin renders (defineAdmin derives them, so no source file names one): ${shown.join(', ')}${more}`,
|
|
174
|
+
fix: `add to ${path}, each with its real label: ${shown.join(', ')}${more} — then x i18n check --json`,
|
|
175
|
+
at: path,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
143
179
|
/**
|
|
144
180
|
* `packages/i18n/src/index.ts` as text, or `undefined` where the app has no i18n package. Read
|
|
145
181
|
* here and nowhere lower down: `@ultimat3/i18n` states that it never reads a file, and the CLI is
|
package/src/image-prepare.ts
CHANGED
|
@@ -1,14 +1,57 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The two halves of an image build that are this framework's. On the host, `x build --target
|
|
2
|
+
// docker` stamps the image with the manifest's build id. INSIDE the image build, `x build --target
|
|
3
|
+
// prebuilt` writes what every web pod would otherwise make on every boot: the island chunks and
|
|
4
|
+
// the compiled stylesheets (`serve-prebuilt-paths.ts` names the store and why it is where it is).
|
|
4
5
|
|
|
6
|
+
// why: Bun ships no recursive remove; a previous store must not survive beside the new one.
|
|
7
|
+
import { rm } from 'node:fs/promises';
|
|
8
|
+
// why: Bun ships no path-join primitive.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { sassCompilations, setSassCacheDir } from '@ultimat3/render/server';
|
|
11
|
+
import { scanAppModules } from './app-load';
|
|
5
12
|
import { appManifest } from './app-manifest';
|
|
6
13
|
import { buildIslands } from './island-bundle';
|
|
7
14
|
import { writeIslandStore } from './island-store';
|
|
15
|
+
import type { Finding } from './output';
|
|
16
|
+
import { PREBUILT_DIR, PREBUILT_SASS_DIR } from './serve-prebuilt-paths';
|
|
8
17
|
|
|
9
|
-
/**
|
|
10
|
-
export async function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
18
|
+
/** The build id `docker build` is handed as `--build-arg BUILD_ID=…`. */
|
|
19
|
+
export async function imageBuildId(root: string): Promise<string> {
|
|
20
|
+
return (await appManifest(root)).manifest.buildId;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PrebuiltImage {
|
|
24
|
+
/** App-root-relative, POSIX. */
|
|
25
|
+
readonly dir: string;
|
|
26
|
+
/** Island chunks written, shared chunks included. */
|
|
27
|
+
readonly islands: number;
|
|
28
|
+
/** Stylesheets compiled into the store. */
|
|
29
|
+
readonly stylesheets: number;
|
|
30
|
+
/** A module that would not import: its stylesheets are not in the store. */
|
|
31
|
+
readonly findings: readonly Finding[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where it runs is the design. The store is valid for one Bun, one framework version and one set
|
|
36
|
+
* of absolute paths — a chunk is verified against the runtime that built it, and a Sass entry is
|
|
37
|
+
* keyed by the sheet's path — so it is written by the image's own Bun, at the image's own
|
|
38
|
+
* `WORKDIR`, after the source is in place. Built on a laptop and copied in, it was a store for a
|
|
39
|
+
* different runtime; and under `.x/` it was dropped by the ignore file before that mattered.
|
|
40
|
+
*
|
|
41
|
+
* The app is imported the way the web role imports it (`scanAppModules`, every module), because
|
|
42
|
+
* importing a page is what compiles its stylesheet: what this scan compiles is what a pod reads.
|
|
43
|
+
*/
|
|
44
|
+
export async function prebuildImage(root: string): Promise<PrebuiltImage> {
|
|
45
|
+
await rm(join(root, PREBUILT_DIR), { recursive: true, force: true });
|
|
46
|
+
setSassCacheDir(join(root, PREBUILT_SASS_DIR));
|
|
47
|
+
const compiled = sassCompilations();
|
|
48
|
+
const scan = await scanAppModules(root, { track: false });
|
|
49
|
+
const bundle = await buildIslands(root);
|
|
50
|
+
await writeIslandStore(root, bundle);
|
|
51
|
+
return {
|
|
52
|
+
dir: PREBUILT_DIR,
|
|
53
|
+
islands: bundle.chunks.length + bundle.shared.length,
|
|
54
|
+
stylesheets: sassCompilations() - compiled,
|
|
55
|
+
findings: scan.findings,
|
|
56
|
+
};
|
|
14
57
|
}
|