@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
|
@@ -45,7 +45,7 @@ export const config = defineRoute({
|
|
|
45
45
|
}),
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
export function
|
|
48
|
+
export function Page() {
|
|
49
49
|
const t = useT();
|
|
50
50
|
|
|
51
51
|
return (
|
|
@@ -259,23 +259,49 @@ const siteStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
|
259
259
|
}
|
|
260
260
|
`;
|
|
261
261
|
|
|
262
|
-
const sitePageTest =
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
import {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
expect(
|
|
262
|
+
const sitePageTest = (
|
|
263
|
+
app: NameSet,
|
|
264
|
+
): string => `// The landing page, rendered: what a visitor reads, where its two calls to action lead, and that
|
|
265
|
+
// it ships zero JS. Each is a promise the file makes, and each rots silently on one import.
|
|
266
|
+
${sortedImports([
|
|
267
|
+
`import { useT } from '@${app.kebab}/i18n';`,
|
|
268
|
+
"import { expect, renderRoute, unitTest } from '@ultimat3/testing';",
|
|
269
|
+
])}
|
|
270
|
+
import * as page from './page';
|
|
271
|
+
|
|
272
|
+
const url = 'https://example.test/';
|
|
273
|
+
|
|
274
|
+
unitTest('the landing page says what the catalog says, under one h1', async () => {
|
|
275
|
+
const t = useT();
|
|
276
|
+
const view = await renderRoute(page, { url });
|
|
277
|
+
expect(view.html.match(/<h1\\b/g)).toHaveLength(1);
|
|
278
|
+
expect(view.text).toContain(t('site.home.headline'));
|
|
279
|
+
expect(view.text).toContain(t('site.home.lede'));
|
|
280
|
+
// Three facts, each a heading and a body.
|
|
281
|
+
expect(view.html.match(/<h2\\b/g)).toHaveLength(3);
|
|
282
|
+
for (const key of ['site.home.f1Title', 'site.home.f2Body', 'site.home.f3Title'] as const) {
|
|
283
|
+
expect(view.text).toContain(t(key));
|
|
284
|
+
}
|
|
285
|
+
// A key no catalog holds renders as \u27e6key\u27e7 — to a visitor, with every other check green.
|
|
286
|
+
expect(view.text).not.toContain('\u27e6');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
unitTest('its two calls to action lead into the product', async () => {
|
|
290
|
+
const view = await renderRoute(page, { url });
|
|
291
|
+
const hrefs = [...view.html.matchAll(/<a\\b[^>]*href="([^"]*)"/g)].map((match) => match[1]);
|
|
292
|
+
expect(hrefs).toEqual(['/dashboard', '/admin']);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
unitTest('it ships zero JS and declares the metadata a search result renders', async () => {
|
|
296
|
+
const t = useT();
|
|
297
|
+
const view = await renderRoute(page, { url });
|
|
298
|
+
expect(page.config.render).toBe('static');
|
|
299
|
+
expect(page.config.hydrate).toBe('never');
|
|
300
|
+
expect(page.config.budget.js).toBe('0kb');
|
|
301
|
+
// What the render DID, beside what the config declares: no island left the page.
|
|
302
|
+
expect(view.islands).toEqual([]);
|
|
303
|
+
expect(view.meta.title).toBe(t('site.home.title'));
|
|
304
|
+
expect(view.meta.description).toBe(t('site.home.description'));
|
|
279
305
|
});
|
|
280
306
|
`;
|
|
281
307
|
|
|
@@ -283,5 +309,5 @@ unitTest('the landing page ships zero JS and declares metadata', async () => {
|
|
|
283
309
|
export const siteFiles = (app: NameSet): readonly GeneratedFile[] => [
|
|
284
310
|
{ path: 'apps/web/site/page.tsx', contents: sitePage(app) },
|
|
285
311
|
{ path: 'apps/web/site/page.module.scss', contents: siteStyle() },
|
|
286
|
-
{ path: 'apps/web/site/page.test.ts', contents: sitePageTest() },
|
|
312
|
+
{ path: 'apps/web/site/page.test.ts', contents: sitePageTest(app) },
|
|
287
313
|
];
|
|
@@ -31,6 +31,23 @@ export function Card(props: CardProps) {
|
|
|
31
31
|
}
|
|
32
32
|
`;
|
|
33
33
|
|
|
34
|
+
const uiCardTest =
|
|
35
|
+
(): string => `// The card, rendered the way a page renders it. What a typecheck cannot see is where a prop
|
|
36
|
+
// lands: the title is the card's heading, and the children follow it inside the same section.
|
|
37
|
+
import { expect, renderView, unitTest } from '@ultimat3/testing';
|
|
38
|
+
import { Card } from './card';
|
|
39
|
+
|
|
40
|
+
unitTest('the card puts its title in a heading and its children after it', async () => {
|
|
41
|
+
const view = await renderView(Card, { title: 'Usage', children: 'body copy' });
|
|
42
|
+
expect(view.html).toMatch(/^<section[^>]*><h2[^>]*>Usage<\\/h2>body copy<\\/section>$/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
unitTest('a card with no children is its heading and nothing else', async () => {
|
|
46
|
+
const view = await renderView(Card, { title: 'Empty' });
|
|
47
|
+
expect(view.text).toBe('Empty');
|
|
48
|
+
});
|
|
49
|
+
`;
|
|
50
|
+
|
|
34
51
|
const uiCardStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
35
52
|
|
|
36
53
|
.card {
|
|
@@ -52,5 +69,6 @@ export const uiPackageFiles = (app: NameSet): readonly GeneratedFile[] => [
|
|
|
52
69
|
...packageShapeFiles(app, 'ui', DESCRIPTION),
|
|
53
70
|
{ path: 'packages/ui/src/index.ts', contents: uiIndex() },
|
|
54
71
|
{ path: 'packages/ui/src/card.tsx', contents: uiCard() },
|
|
72
|
+
{ path: 'packages/ui/src/card.test.ts', contents: uiCardTest() },
|
|
55
73
|
{ path: 'packages/ui/src/card.module.scss', contents: uiCardStyle() },
|
|
56
74
|
];
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// none of them, so each emitted TS2307 in any slice a resource had not been run in first.
|
|
5
5
|
|
|
6
6
|
import { stripComments } from '@ultimat3/core';
|
|
7
|
+
import { adminCatalogFiles } from './admin-catalog';
|
|
7
8
|
import type { FeatureTarget } from './entity';
|
|
8
9
|
import { entityFiles } from './entity';
|
|
9
10
|
import type { GeneratedFile, NameSet } from './naming';
|
|
@@ -74,16 +75,24 @@ const ifAbsent = (files: readonly GeneratedFile[]): readonly GeneratedFile[] =>
|
|
|
74
75
|
export function sliceFoundation(
|
|
75
76
|
target: FeatureTarget,
|
|
76
77
|
needs: readonly SliceModule[],
|
|
78
|
+
/** Every locale the planted entity's admin labels ship for. Defaults to `['en']`. */
|
|
79
|
+
locales?: readonly string[],
|
|
77
80
|
): readonly GeneratedFile[] {
|
|
78
81
|
const feature = names(target.feature);
|
|
79
82
|
const dir = `${target.surfaceDir}/${target.feature}`;
|
|
80
|
-
return
|
|
81
|
-
...(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
return [
|
|
84
|
+
...ifAbsent([
|
|
85
|
+
...(needs.includes('entity') ? entityFiles(target.feature, target) : []),
|
|
86
|
+
...(needs.includes('policy') ? policyFiles(target.feature, target) : []),
|
|
87
|
+
...(needs.includes('errors')
|
|
88
|
+
? [{ path: `${dir}/errors.ts`, contents: errorsSource(feature) }]
|
|
89
|
+
: []),
|
|
90
|
+
]),
|
|
91
|
+
// A planted entity joins the typed handle, and an entity in the handle IS an admin screen —
|
|
92
|
+
// so it gets the labels `x g entity` writes for one, or its nav entry renders a missing-key
|
|
93
|
+
// marker. A catalog MERGE, never `if-absent`: a key already there keeps the value it has.
|
|
94
|
+
...(needs.includes('entity') ? adminCatalogFiles(target.feature, target, locales) : []),
|
|
95
|
+
];
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
/** The exported names an `export { a, b as c }` list declares — `c`, never `b`. */
|
|
@@ -117,10 +126,39 @@ export function sliceExports(source: string, name: string): boolean {
|
|
|
117
126
|
const code = stripComments(source);
|
|
118
127
|
// `(?:async\s+)?` before `function`: an `export async function byId` — every repo function
|
|
119
128
|
// `x g entity` scaffolds — matched neither this nor `listedExports`, so a caller checking for
|
|
120
|
-
// `byId`/`
|
|
129
|
+
// `byId`/`list` on a real repo.ts always read `false`. `async` has no meaning before
|
|
121
130
|
// `class`/`const`/`let`/`var`/`enum`, so it is scoped to `function` only.
|
|
122
131
|
const declared = new RegExp(
|
|
123
132
|
`\\bexport\\s+(?:abstract\\s+)?(?:class|const|let|var|(?:async\\s+)?function|enum)\\s+${name}\\b`,
|
|
124
133
|
);
|
|
125
134
|
return declared.test(code) || listedExports(code).includes(name);
|
|
126
135
|
}
|
|
136
|
+
|
|
137
|
+
/** ` title: text({ max: 200 }),` — one column of an entity, as its name and its builder. */
|
|
138
|
+
const COLUMN_LINE = /^ {4}([A-Za-z_$][\w$]*): ([A-Za-z_$][\w$]*)\(/gm;
|
|
139
|
+
|
|
140
|
+
const columnsOf = (entitySource: string): string =>
|
|
141
|
+
[...stripComments(entitySource).matchAll(COLUMN_LINE)]
|
|
142
|
+
.map((match) => `${match[1] ?? ''}: ${match[2] ?? ''}`)
|
|
143
|
+
.join(', ');
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Whether a generated TEST may store a row in this slice: its entity declares exactly the columns
|
|
147
|
+
* `x g entity` scaffolds for this feature — read off the entity this generator would write, never
|
|
148
|
+
* a second list — and its `repo.ts`, when one is on disk, still exports `insert`. A slice an author
|
|
149
|
+
* reshaped has a row this generator cannot spell (one more required column is enough), so its
|
|
150
|
+
* tests assert the branches that need none and leave the stored-row case to the author.
|
|
151
|
+
*/
|
|
152
|
+
export function sliceTakesScaffoldRow(
|
|
153
|
+
target: FeatureTarget,
|
|
154
|
+
sliceEntity: string | undefined,
|
|
155
|
+
sliceRepo: string | undefined,
|
|
156
|
+
): boolean {
|
|
157
|
+
if (sliceEntity === undefined) return false;
|
|
158
|
+
const scaffold = entityFiles(target.feature, target).find((file) =>
|
|
159
|
+
file.path.endsWith('/entity.ts'),
|
|
160
|
+
);
|
|
161
|
+
const wanted = columnsOf(String(scaffold?.contents ?? ''));
|
|
162
|
+
if (wanted === '' || columnsOf(sliceEntity) !== wanted) return false;
|
|
163
|
+
return sliceRepo === undefined || sliceExports(sliceRepo, 'insert');
|
|
164
|
+
}
|
package/src/test-counts.ts
CHANGED
|
@@ -11,8 +11,21 @@ export interface TestCounts {
|
|
|
11
11
|
readonly ran: number;
|
|
12
12
|
/** Tests bun reported as skipped or todo, which is the same thing here: they did not run. */
|
|
13
13
|
readonly skipped: number;
|
|
14
|
+
/**
|
|
15
|
+
* Errors raised OUTSIDE any test — a `describe` body or a module that threw on load, a failed
|
|
16
|
+
* hook — which bun counts on a line of their own (`1 error`) beside `0 fail`. Present only when
|
|
17
|
+
* there were some: the exit code already makes the step red, and without this number the step
|
|
18
|
+
* line read "12 ran, 0 skipped" over a run whose file never got as far as declaring a test.
|
|
19
|
+
*/
|
|
20
|
+
readonly errors?: number;
|
|
14
21
|
}
|
|
15
22
|
|
|
23
|
+
/** Bun's own `N error` / `N errors` summary line: the last one, as `parseBunTest` reads the rest. */
|
|
24
|
+
const errorsIn = (output: string): number => {
|
|
25
|
+
const last = [...output.matchAll(/^\s*(\d+)\s+errors?\s*$/gm)].at(-1);
|
|
26
|
+
return last === undefined ? 0 : Number.parseInt(last[1] ?? '0', 10);
|
|
27
|
+
};
|
|
28
|
+
|
|
16
29
|
/**
|
|
17
30
|
* Summed across every process the step spawned, because a step is one line in the gate whether it
|
|
18
31
|
* ran on one worker or eight.
|
|
@@ -26,10 +39,13 @@ export interface TestCounts {
|
|
|
26
39
|
export const countsOf = (results: readonly ExecResult[]): TestCounts => {
|
|
27
40
|
let ran = 0;
|
|
28
41
|
let skipped = 0;
|
|
42
|
+
let errors = 0;
|
|
29
43
|
for (const result of results) {
|
|
30
|
-
const
|
|
44
|
+
const output = execOutput(result);
|
|
45
|
+
const run = parseBunTest(output, result.durationMs);
|
|
31
46
|
ran += run.passed + run.failed;
|
|
32
47
|
skipped += run.skipped;
|
|
48
|
+
errors += errorsIn(output);
|
|
33
49
|
}
|
|
34
|
-
return { ran, skipped };
|
|
50
|
+
return { ran, skipped, ...(errors === 0 ? {} : { errors }) };
|
|
35
51
|
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The four ways browser code opens its own connection, found in ONE file's text: a `fetch(` CALL,
|
|
2
|
+
// `new WebSocket(`, `new XMLHttpRequest(`, `new EventSource(`. Pure, so every false positive the
|
|
3
|
+
// rule has to leave alone is a fixture string. Which file may hold which shape is the caller's.
|
|
4
|
+
|
|
5
|
+
import { maskLiterals } from '@ultimat3/core';
|
|
6
|
+
import { lineIndex } from './ts-scan';
|
|
7
|
+
|
|
8
|
+
export type TransportShape = 'fetch' | 'websocket' | 'xhr' | 'eventsource';
|
|
9
|
+
|
|
10
|
+
export interface TransportCall {
|
|
11
|
+
readonly shape: TransportShape;
|
|
12
|
+
readonly line: number;
|
|
13
|
+
/** What the source spells — `fetch`, `globalThis.fetch`, `WebSocket` — so a finding quotes it. */
|
|
14
|
+
readonly spelled: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CONSTRUCTED: ReadonlyMap<string, TransportShape> = new Map([
|
|
18
|
+
['WebSocket', 'websocket'],
|
|
19
|
+
['XMLHttpRequest', 'xhr'],
|
|
20
|
+
['EventSource', 'eventsource'],
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/** The global object's three spellings. `server.fetch(` is a method, never the global. */
|
|
24
|
+
const GLOBAL = String.raw`(?:(?:globalThis|window|self)\s*\.\s*)`;
|
|
25
|
+
const NEW = new RegExp(
|
|
26
|
+
String.raw`\bnew\s+${GLOBAL}?(WebSocket|XMLHttpRequest|EventSource)\s*\(`,
|
|
27
|
+
'g',
|
|
28
|
+
);
|
|
29
|
+
const FETCH = new RegExp(String.raw`(?<![\w$.])(${GLOBAL}?)fetch\s*\(`, 'g');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether the file binds `fetch` itself — a `const`/`let`/`var`/`function`, an import, a
|
|
33
|
+
* destructured declaration or a parameter — which makes a BARE `fetch(` its own function, not the
|
|
34
|
+
* page's. `packages/action/src/idempotency-postgres.ts` calls a local `fetch(key)`. Per file, not
|
|
35
|
+
* per scope: a floor, and the one direction it errs is silence on a file that shadows the global
|
|
36
|
+
* AND calls it bare. `globalThis.fetch(` is reported regardless.
|
|
37
|
+
*/
|
|
38
|
+
export function bindsFetch(code: string): boolean {
|
|
39
|
+
return (
|
|
40
|
+
/\b(?:const|let|var|function)\s+fetch\b/.test(code) ||
|
|
41
|
+
/\bimport\s+[^;]*\bfetch\b[^;]*\bfrom\b/.test(code) ||
|
|
42
|
+
/\b(?:const|let|var)\s*\{[^}]*\bfetch\b[^}]*\}\s*=/.test(code) ||
|
|
43
|
+
/\(\s*(?:[\w$]+\s*(?::[^,()]+)?,\s*)*fetch\s*[?:,)=][^()]*\)\s*(?::[^={;]+)?(?:=>|\{)/.test(
|
|
44
|
+
code,
|
|
45
|
+
)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The `)` closing the `(` at `open`, or `-1`. A bare depth count is exact HERE and only here: the
|
|
51
|
+
* text is `maskLiterals`' output, where every string, template, comment and regex body is already
|
|
52
|
+
* blank — so each parenthesis left is code.
|
|
53
|
+
*/
|
|
54
|
+
function closeOf(masked: string, open: number): number {
|
|
55
|
+
let depth = 0;
|
|
56
|
+
for (let index = open; index < masked.length; index += 1) {
|
|
57
|
+
if (masked[index] === '(') depth += 1;
|
|
58
|
+
else if (masked[index] === ')') {
|
|
59
|
+
depth -= 1;
|
|
60
|
+
if (depth === 0) return index;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return -1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `fetch(req) {` and `fetch(req): Promise<Response> {` define a method; they call nothing. */
|
|
67
|
+
function isDefinition(masked: string, open: number, start: number): boolean {
|
|
68
|
+
if (/\b(?:function|async|get|set)\s*$/.test(masked.slice(Math.max(0, start - 16), start))) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
const close = closeOf(masked, open);
|
|
72
|
+
if (close < 0) return false;
|
|
73
|
+
const after = masked.slice(close + 1).trimStart();
|
|
74
|
+
return after.startsWith('{') || (after.startsWith(':') && !after.startsWith('::'));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A file naming none of the four cannot call one — and masking it is the expensive half. */
|
|
78
|
+
const NAMES_A_TRANSPORT = /fetch|WebSocket|XMLHttpRequest|EventSource/;
|
|
79
|
+
|
|
80
|
+
export function transportCalls(source: string): readonly TransportCall[] {
|
|
81
|
+
if (!NAMES_A_TRANSPORT.test(source)) return [];
|
|
82
|
+
// Comments AND string contents blanked, offsets kept: `'fetch('` in a message is not a call, and
|
|
83
|
+
// neither is the one a template-literal scaffold emits for somebody else's file.
|
|
84
|
+
const masked = maskLiterals(source);
|
|
85
|
+
const lineAt = lineIndex(masked);
|
|
86
|
+
const calls: TransportCall[] = [];
|
|
87
|
+
for (const found of masked.matchAll(NEW)) {
|
|
88
|
+
const name = found[1] ?? '';
|
|
89
|
+
const shape = CONSTRUCTED.get(name);
|
|
90
|
+
if (shape !== undefined) calls.push({ shape, line: lineAt(found.index), spelled: name });
|
|
91
|
+
}
|
|
92
|
+
const local = bindsFetch(masked);
|
|
93
|
+
for (const found of masked.matchAll(FETCH)) {
|
|
94
|
+
const qualified = (found[1] ?? '').length > 0;
|
|
95
|
+
if (!qualified && local) continue;
|
|
96
|
+
const open = found.index + found[0].length - 1;
|
|
97
|
+
if (!qualified && isDefinition(masked, open, found.index)) continue;
|
|
98
|
+
calls.push({
|
|
99
|
+
shape: 'fetch',
|
|
100
|
+
line: lineAt(found.index),
|
|
101
|
+
spelled: qualified ? `${(found[1] ?? '').replace(/\s/g, '')}fetch` : 'fetch',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return calls.sort((a, b) => a.line - b.line);
|
|
105
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// A `defineAdmin()` the app scan never imports. The admin's one home is `apps/admin/app/admin/`
|
|
2
|
+
// (`ADMIN_FILE`, what `x new` and `x g admin:page` write); `apps/<app>/src/` is outside every
|
|
3
|
+
// glob `app-load.ts` imports, so a declaration there never runs and `/admin` is never mounted —
|
|
4
|
+
// silently, which is how the reference app shipped an admin nobody could open.
|
|
5
|
+
|
|
6
|
+
import { renderFixShellArg, stripComments } from '@ultimat3/core';
|
|
7
|
+
import { describeRoutes } from '@ultimat3/render';
|
|
8
|
+
import { ADMIN_FILE } from './admin-registration';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
import { hasPathSegment } from './path-segments';
|
|
11
|
+
import { isTest } from './source-files';
|
|
12
|
+
|
|
13
|
+
/** The one place an admin used to be written that the scan cannot see. */
|
|
14
|
+
const UNSCANNED = 'apps/*/src/**/*.{ts,tsx}';
|
|
15
|
+
|
|
16
|
+
const DECLARES = /\bdefineAdmin\(/;
|
|
17
|
+
|
|
18
|
+
/** Any admin this process mounted — then every declaration was reached, wherever it sits. */
|
|
19
|
+
export const adminMounted = (): boolean =>
|
|
20
|
+
describeRoutes().some((route) => route.mount?.by === 'defineAdmin');
|
|
21
|
+
|
|
22
|
+
/** Where the file belongs: its own name under the admin's home, `index.ts` as the declaration. */
|
|
23
|
+
const homeOf = (file: string): string => {
|
|
24
|
+
const name = file.slice(file.lastIndexOf('/') + 1);
|
|
25
|
+
const home = ADMIN_FILE.slice(0, ADMIN_FILE.lastIndexOf('/'));
|
|
26
|
+
return /^index\.tsx?$/.test(name) ? ADMIN_FILE : `${home}/${name}`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const unscannedFinding = (file: string): Finding => ({
|
|
30
|
+
code: 'X_ADMIN_UNSCANNED',
|
|
31
|
+
cause: `${file} calls defineAdmin(), and the app scan imports apps/*/{site,app,api,shared}/** only — no boot evaluates it, so the admin is never mounted and /admin answers 404`,
|
|
32
|
+
// A file name is the app's: screened, so a name carrying shell syntax reads as a placeholder.
|
|
33
|
+
fix: `git mv ${renderFixShellArg(file, '<the file above>')} ${renderFixShellArg(homeOf(file), '<apps/admin/app/admin/its-name.ts>')} # then repoint its relative imports and x verify --only manifest --json`,
|
|
34
|
+
at: file,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One finding per unscanned file that declares an admin. Read only when nothing mounted one: an app
|
|
39
|
+
* whose admin is mounted pays for no glob, and comments are masked so prose naming the call is not
|
|
40
|
+
* a declaration.
|
|
41
|
+
*/
|
|
42
|
+
export async function unscannedAdminFindings(
|
|
43
|
+
root: string,
|
|
44
|
+
mounted: () => boolean = adminMounted,
|
|
45
|
+
): Promise<readonly Finding[]> {
|
|
46
|
+
if (mounted()) return [];
|
|
47
|
+
const files: string[] = [];
|
|
48
|
+
for await (const file of new Bun.Glob(UNSCANNED).scan({ cwd: root })) {
|
|
49
|
+
const path = file.replaceAll('\\', '/');
|
|
50
|
+
if (hasPathSegment(path, 'node_modules') || isTest(path)) continue;
|
|
51
|
+
if (DECLARES.test(stripComments(await Bun.file(`${root}/${path}`).text()))) files.push(path);
|
|
52
|
+
}
|
|
53
|
+
return files.sort().map(unscannedFinding);
|
|
54
|
+
}
|
package/src/verify-checks.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import type { MetaIssue } from '@ultimat3/seo';
|
|
16
16
|
import { validateMeta } from '@ultimat3/seo';
|
|
17
17
|
import { checkAgentsMd } from './app-agents-md';
|
|
18
|
+
import { readAppSources } from './app-boundaries';
|
|
18
19
|
import { envExampleFindings } from './app-env';
|
|
19
20
|
import { appManifest, readAppManifest } from './app-manifest';
|
|
20
21
|
import { OPENAPI_FILE, openApiStaleness } from './app-openapi';
|
|
@@ -36,10 +37,12 @@ import { msg } from './messages';
|
|
|
36
37
|
import type { Finding } from './output';
|
|
37
38
|
import { findingFrom } from './output';
|
|
38
39
|
import { checkMigrationDrift } from './schema-drift';
|
|
40
|
+
import { checkSchemaDump } from './schema-dump-drift';
|
|
39
41
|
import { scanSiteMeta } from './seo-meta';
|
|
40
42
|
import { quoteArg } from './shell-quote';
|
|
41
43
|
import { readStaticReport } from './static-report';
|
|
42
44
|
import { floorProblemFindings, readVerifyFloor } from './verify-floor';
|
|
45
|
+
import { roleLoadFindings } from './verify-role-load';
|
|
43
46
|
import type { VerifyStep } from './verify-step';
|
|
44
47
|
import { fromExec, fromFindings, hostFindings } from './verify-step';
|
|
45
48
|
import { TEST_STEPS } from './verify-tests';
|
|
@@ -90,7 +93,8 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
90
93
|
},
|
|
91
94
|
{
|
|
92
95
|
name: 'boundaries',
|
|
93
|
-
summary:
|
|
96
|
+
summary:
|
|
97
|
+
"surface, layer and package-tier imports, one browser transport, and the app's own guards",
|
|
94
98
|
// An app's `guards/` rides here rather than becoming a step of its own, for the reason the
|
|
95
99
|
// seam already states: a host adds findings to a step, it can never add, remove, reorder or
|
|
96
100
|
// skip one — so "green" keeps meaning exactly what it meant. This is the step whose host slot
|
|
@@ -101,12 +105,18 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
101
105
|
// Discovered, not registered: `guardFindings` reads the directory. A guard that had to
|
|
102
106
|
// announce itself is a guard an app can forget to announce, which is the coupling axiom 8's
|
|
103
107
|
// extension model rejects.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
//
|
|
109
|
+
// ONE read of the app's surface files for the whole step: the import rules, the transport
|
|
110
|
+
// rule and every guard are handed the same text. The guards used to walk and read the tree a
|
|
111
|
+
// second time (`guard-sources.ts`).
|
|
112
|
+
async run(ctx) {
|
|
113
|
+
const sources = await readAppSources(ctx.root);
|
|
114
|
+
return fromFindings([
|
|
115
|
+
...(await appBoundaryFindings(ctx.root, sources)),
|
|
116
|
+
...(await guardFindings(ctx.root, sources)),
|
|
108
117
|
...(await hostFindings(ctx, 'boundaries')),
|
|
109
|
-
])
|
|
118
|
+
]);
|
|
119
|
+
},
|
|
110
120
|
},
|
|
111
121
|
{
|
|
112
122
|
name: 'filesize',
|
|
@@ -161,7 +171,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
161
171
|
{
|
|
162
172
|
name: 'drift',
|
|
163
173
|
summary:
|
|
164
|
-
'entity declarations vs migrations, every destructive statement declared,
|
|
174
|
+
'entity declarations vs migrations, every destructive statement declared, every statement no declaration carries, and the committed schema dump',
|
|
165
175
|
// Only an app owns migrations; a package monorepo's `packages/db` is the driver, not a schema.
|
|
166
176
|
// Source, not database: the gate runs in CI with nothing listening, and the database half is
|
|
167
177
|
// the post-migrate verification `runMigrations` performs where a connection is already open.
|
|
@@ -179,6 +189,10 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
179
189
|
// a squash silently drops. Same directory, same reader, no database — this step's own
|
|
180
190
|
// question, which is why it is not a step of its own.
|
|
181
191
|
...(await checkUngeneratableMigrations(ctx.root)),
|
|
192
|
+
// The fourth, and the only one that opens a database — a scratch embedded one it boots
|
|
193
|
+
// itself, so CI still needs nothing listening: the committed `packages/db/schema/` against
|
|
194
|
+
// what replaying the migrations produces, and that dump loaded back (`X_SCHEMA_DUMP_DRIFT`).
|
|
195
|
+
...(await checkSchemaDump(ctx.root, ctx.env ?? Bun.env)),
|
|
182
196
|
]),
|
|
183
197
|
},
|
|
184
198
|
{
|
|
@@ -328,6 +342,8 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
328
342
|
const agents = await checkAgentsMd(ctx.root, floor?.agentsMdMaxBytes);
|
|
329
343
|
// The app's load failures, once, here — every other step that loaded it points at this one.
|
|
330
344
|
const app = existsSync(join(ctx.root, APP_CONFIG_FILE));
|
|
345
|
+
// Started first and awaited last: it is a child process, and it runs beside the rest.
|
|
346
|
+
const roleLoad = app ? roleLoadFindings(ctx.root, ctx.runner) : Promise.resolve([]);
|
|
331
347
|
const drift = await driftFindings(ctx.root);
|
|
332
348
|
const findings = [
|
|
333
349
|
...manifestMissingFindings(ctx.root),
|
|
@@ -336,6 +352,9 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
336
352
|
...(existsSync(join(ctx.root, APP_CONFIG_FILE))
|
|
337
353
|
? await unregisteredJobFindings(ctx.root)
|
|
338
354
|
: []),
|
|
355
|
+
// What a worker never registers: a declaration beside a component the API index does not
|
|
356
|
+
// import (`verify-role-load.ts`). The boot checks jobs and tasks only, and only at boot.
|
|
357
|
+
...(await roleLoad),
|
|
339
358
|
...(await envExampleFindings(ctx.root)),
|
|
340
359
|
...floorProblemFindings(floor),
|
|
341
360
|
...agents.findings,
|