@ultimat3/cli 22.14.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 +25 -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 +35 -3
- 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-dev.ts +2 -2
- 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 +30 -6
- 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/document-options.ts +64 -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/page-speculation.ts +124 -0
- package/src/permission-grants.ts +41 -9
- package/src/prerender.ts +9 -5
- 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 +36 -60
- 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 -147
- 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/static-document.ts +108 -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
|
@@ -3,39 +3,13 @@
|
|
|
3
3
|
// One live run per name: a second enqueue while the pass is going is the same pass.
|
|
4
4
|
|
|
5
5
|
import type { FeatureTarget } from './entity';
|
|
6
|
+
import { sortedImports } from './imports';
|
|
6
7
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
8
|
import { names } from './naming';
|
|
9
|
+
import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
|
|
8
10
|
import { sliceFoundation } from './slice-foundation';
|
|
9
11
|
import { wrapImport } from './wrap';
|
|
10
12
|
|
|
11
|
-
/**
|
|
12
|
-
* What the entity's value export is called inside the generated file. `x g backfill invoice
|
|
13
|
-
* --feature invoice` is a legal invocation and it emitted `import { invoice } from '../entity'`
|
|
14
|
-
* beside `export const invoice = backfill(...)` — one name, two declarations, which is
|
|
15
|
-
* `lint/suspicious/noRedeclare` in the app's own gate and a genuinely ambiguous reference in TS.
|
|
16
|
-
* Aliased only when it would collide, because every other backfill reads better without one.
|
|
17
|
-
*/
|
|
18
|
-
const entityRef = (name: NameSet, feature: NameSet): string =>
|
|
19
|
-
name.camel === feature.camel ? `${feature.camel}Entity` : feature.camel;
|
|
20
|
-
|
|
21
|
-
const entityImport = (name: NameSet, feature: NameSet): string => {
|
|
22
|
-
const local = entityRef(name, feature);
|
|
23
|
-
return local === feature.camel ? feature.camel : `${feature.camel} as ${local}`;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The chain accessor, wrapped the way Biome would wrap it. Emitted pre-formatted rather than
|
|
28
|
-
* always-wrapped because the formatter joins an arrow body back onto one line when it fits — so a
|
|
29
|
-
* fixed shape is wrong for one name length or the other. Same reason `policy.ts` measures its
|
|
30
|
-
* `definePermissions` line.
|
|
31
|
-
*/
|
|
32
|
-
const tableLine = (name: NameSet, feature: NameSet): string => {
|
|
33
|
-
const ref = entityRef(name, feature);
|
|
34
|
-
const head = `const ${feature.camel}Table = () =>`;
|
|
35
|
-
const body = `tableFor(${ref}, postgresRepo(${ref}));`;
|
|
36
|
-
return `${head} ${body}`.length <= 100 ? `${head} ${body}` : `${head}\n ${body}`;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
13
|
/**
|
|
40
14
|
* Working source, never a stub: a generated `throw new Error(…)` carries no `X_*` code and a
|
|
41
15
|
* generated no-op handler checkpoints a page it never wrote, which reports swept rows nobody
|
|
@@ -45,32 +19,29 @@ const tableLine = (name: NameSet, feature: NameSet): string => {
|
|
|
45
19
|
const backfillSource = (
|
|
46
20
|
name: NameSet,
|
|
47
21
|
feature: NameSet,
|
|
22
|
+
dbModule: string,
|
|
48
23
|
): string => `// ${name.camel}: one pass over a chain of rows. The backfill is a job factory, not a ninth
|
|
49
24
|
// primitive, so it inherits .enqueue(), retry, cancellation and the manifest row.
|
|
50
25
|
// \`BackfillBatch\` comes from @ultimat3/jobs, not @ultimat3/schema: a backfill file imports one package.
|
|
51
26
|
|
|
52
|
-
|
|
53
|
-
import { assert, hasScope } from '@ultimat3/core';
|
|
54
|
-
import type
|
|
55
|
-
import {
|
|
56
|
-
import
|
|
57
|
-
|
|
27
|
+
${sortedImports([
|
|
28
|
+
"import { assert, type Ctx, hasScope } from '@ultimat3/core';",
|
|
29
|
+
"import { CROSS_TENANT_SCOPE, type ReadBuilder } from '@ultimat3/entity';",
|
|
30
|
+
"import { type BackfillBatch, backfill } from '@ultimat3/jobs';",
|
|
31
|
+
`import { db } from '${dbModule}';`,
|
|
32
|
+
])}
|
|
58
33
|
import type { ${feature.pascal} } from '../entity';
|
|
59
|
-
import { ${entityImport(name, feature)} } from '../entity';
|
|
60
34
|
|
|
61
35
|
/** The row this sweep visits, aliased once: every signature below then reads at one width. */
|
|
62
36
|
type Row = ${feature.pascal};
|
|
63
37
|
|
|
64
|
-
/** The table as a chain — the seam \`database()\` hands an app, so this sweep reads what a query reads. */
|
|
65
|
-
${tableLine(name, feature)}
|
|
66
|
-
|
|
67
38
|
/**
|
|
68
|
-
* The rows this pass visits
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
39
|
+
* The rows this pass visits, read through the app's typed handle — the table a query reads. A
|
|
40
|
+
* one-pass sweep has no single org, so it declares \`tenant: 'none'\` below — which STRIPS the org
|
|
41
|
+
* from the run rather than inheriting the worker's. That makes spanning tenants a capability
|
|
42
|
+
* instead of an accident: the actor this worker runs as has to carry \`tenancy:cross\`, and this is
|
|
43
|
+
* where that is said, before a page is read rather than inside the plan builder. A single-org
|
|
44
|
+
* sweep is the other shape — declare \`tenant: () => '<org id>'\`, and the handle scopes the read.
|
|
74
45
|
*/
|
|
75
46
|
const ${name.camel}Scope = (ctx: Ctx): ReadBuilder<Row> => {
|
|
76
47
|
assert(
|
|
@@ -79,7 +50,7 @@ const ${name.camel}Scope = (ctx: Ctx): ReadBuilder<Row> => {
|
|
|
79
50
|
// A generated \`fix:\` is copied and run verbatim, so it names a command this build SHIPS.
|
|
80
51
|
'x db backfill ${name.kebab} --write --json',
|
|
81
52
|
);
|
|
82
|
-
return
|
|
53
|
+
return db.${feature.plural};
|
|
83
54
|
};
|
|
84
55
|
|
|
85
56
|
/**
|
|
@@ -103,12 +74,18 @@ export const ${name.camel} = backfill({
|
|
|
103
74
|
tenant: 'none',
|
|
104
75
|
source: ({ ctx }): ReadBuilder<Row> => ${name.camel}Scope(ctx),
|
|
105
76
|
handle: async ({ rows, signal }: BackfillBatch<Row>) => {
|
|
106
|
-
// One page, in its own durable step, at least once.
|
|
107
|
-
//
|
|
108
|
-
//
|
|
77
|
+
// One page, in its own durable step, at least once. The signal is the run cancellation
|
|
78
|
+
// composed with this batch's ceiling, so a cancelled pass stops here instead of writing past
|
|
79
|
+
// its lease.
|
|
109
80
|
signal.throwIfAborted();
|
|
110
|
-
|
|
111
|
-
|
|
81
|
+
// One \`update\` per row, by primary key, and only the column this pass owns — never count + 1.
|
|
82
|
+
// Not \`upsertAll(rows, { onConflict: ['id'] })\`: on a tenant-scoped table a collision judged
|
|
83
|
+
// on \`id\` alone could land on another tenant's row, so the handle refuses it
|
|
84
|
+
// (X_TENANCY_UNSCOPED) and the pass would retry its first page for ever.
|
|
85
|
+
for (const row of rows) {
|
|
86
|
+
const next = ${name.camel}Row(row);
|
|
87
|
+
await db.${feature.plural}.update(row.id, { title: next.title });
|
|
88
|
+
}
|
|
112
89
|
},
|
|
113
90
|
// How many rows still NEED the change — never how many the sweep visits. Declare it once
|
|
114
91
|
// \`source\` narrows to the rows that are actually behind (\`.andWhere('publishedAt', 'is', null)\`
|
|
@@ -127,15 +104,12 @@ export const ${name.camel} = backfill({
|
|
|
127
104
|
|
|
128
105
|
const backfillTest = (
|
|
129
106
|
name: NameSet,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// durable identity — one live run per name, retried under the same key — and that the row
|
|
133
|
-
// projection it applies is idempotent, because a cancelled attempt replays its page whole.
|
|
107
|
+
): string => `// ${name.camel}'s durable identity: one live run per name, retried under the same key. The work
|
|
108
|
+
// itself — the pass over real rows and the projection it applies — is the unit suite's, next door.
|
|
134
109
|
|
|
135
110
|
import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
|
|
136
111
|
import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
|
|
137
|
-
import
|
|
138
|
-
${wrapImport([name.camel, `${name.camel}Row`], `./${name.kebab}`)}
|
|
112
|
+
import { ${name.camel} } from './${name.kebab}';
|
|
139
113
|
|
|
140
114
|
// The driver is process-global, so it is installed and released around this file rather than
|
|
141
115
|
// left behind for whichever test happens to run next.
|
|
@@ -148,15 +122,6 @@ afterAll(resetJobDriver);
|
|
|
148
122
|
// backfill's own name and still fits the formatter width the app's \`lint\` step enforces.
|
|
149
123
|
const expectedKey = '${name.kebab}';
|
|
150
124
|
|
|
151
|
-
const row = (over: Partial<${feature.pascal}> = {}): ${feature.pascal} => ({
|
|
152
|
-
id: '00000000-0000-4000-8000-000000000001',
|
|
153
|
-
orgId: '00000000-0000-4000-8000-000000000002',
|
|
154
|
-
title: ' needs normalising ',
|
|
155
|
-
price: { minor: 1000, currency: 'USD' },
|
|
156
|
-
createdAt: new Date(0),
|
|
157
|
-
...over,
|
|
158
|
-
});
|
|
159
|
-
|
|
160
125
|
jobTest('${name.camel} declares a durable name and retry policy', () => {
|
|
161
126
|
expect(${name.camel}.kind).toBe('job');
|
|
162
127
|
expect(${name.camel}.idempotencyKeyFor({})).toBe(expectedKey);
|
|
@@ -174,19 +139,6 @@ jobTest('${name.camel} projects itself into the manifest', () => {
|
|
|
174
139
|
expect(described.retry.attempts).toBeGreaterThan(0);
|
|
175
140
|
});
|
|
176
141
|
|
|
177
|
-
jobTest('${name.camel} actually rewrites the row it is handed', () => {
|
|
178
|
-
// The declaration alone cannot fail this: a handler that returned without writing would still
|
|
179
|
-
// enqueue, still checkpoint and still report the page as swept.
|
|
180
|
-
expect(${name.camel}Row(row()).title).toBe('needs normalising');
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
jobTest('${name.camel} replays a page idempotently', () => {
|
|
184
|
-
// At least once is the contract: an attempt cancelled between the last row and its checkpoint
|
|
185
|
-
// hands this page to the next attempt. Twice through must equal once through.
|
|
186
|
-
const once = ${name.camel}Row(row());
|
|
187
|
-
expect(${name.camel}Row(once)).toEqual(once);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
142
|
jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
|
|
191
143
|
// One live run per name, forced or not: a second enqueue while the pass is going is the same pass.
|
|
192
144
|
// \`.enqueue()\` is the backfill path — the declared job, queued with no scheduler involved.
|
|
@@ -197,18 +149,100 @@ jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
|
|
|
197
149
|
});
|
|
198
150
|
`;
|
|
199
151
|
|
|
200
|
-
|
|
152
|
+
/**
|
|
153
|
+
* The WORK, on the `unit` step: one whole pass, run by an in-process worker over rows in the
|
|
154
|
+
* in-memory driver, and the projection it applies. An app's coverage floor counts the unit suite
|
|
155
|
+
* alone — and a sweep whose handler nothing ever ran is how a body that could not complete shipped.
|
|
156
|
+
*/
|
|
157
|
+
const backfillUnitTest = (
|
|
158
|
+
name: NameSet,
|
|
159
|
+
feature: NameSet,
|
|
160
|
+
dbModule: string,
|
|
161
|
+
): string => `// ${name.camel}, SWEPT: one whole pass run by a worker in this process, over rows in the in-memory
|
|
162
|
+
// driver. A sweep rewrites rows no user asked it to, so what is worth failing on is that it reaches
|
|
163
|
+
// every tenant's rows, writes the projection and nothing else, and that a replay changes nothing.
|
|
164
|
+
${sortedImports([
|
|
165
|
+
"import { createContext, runWithContext } from '@ultimat3/core';",
|
|
166
|
+
"import { testActor } from '@ultimat3/policy';",
|
|
167
|
+
"import { afterEach, expect, unitTest } from '@ultimat3/testing';",
|
|
168
|
+
`import { db, driver } from '${dbModule}';`,
|
|
169
|
+
])}
|
|
170
|
+
import type { ${feature.pascal} } from '../entity';
|
|
171
|
+
${wrapImport([name.camel, `${name.camel}Row`], `./${name.kebab}`)}
|
|
172
|
+
|
|
173
|
+
const orgA = '00000000-0000-4000-8000-000000000002';
|
|
174
|
+
const orgB = '00000000-0000-4000-8000-000000000009';
|
|
175
|
+
|
|
176
|
+
/** What a request is to the handle: an actor, whose org every read and write runs under. */
|
|
177
|
+
const inOrg = <T>(orgId: string, run: () => Promise<T>): Promise<T> =>
|
|
178
|
+
runWithContext(createContext({ actor: testActor('member', { orgId }).actor }), run);
|
|
179
|
+
|
|
180
|
+
const store = (orgId: string, title: string): Promise<${feature.pascal}> => {
|
|
181
|
+
const draft = { orgId, title, price: { minor: 1000, currency: 'USD' } };
|
|
182
|
+
return inOrg(orgId, () => db.${feature.plural}.insert(draft));
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const titlesOf = (orgId: string): Promise<readonly string[]> =>
|
|
186
|
+
inOrg(orgId, async () => (await db.${feature.plural}.all()).map((row) => row.title));
|
|
187
|
+
|
|
188
|
+
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
189
|
+
afterEach(() => {
|
|
190
|
+
driver.reset?.();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
unitTest('one pass rewrites the rows of every tenant, and completes', async ({ runJobs }) => {
|
|
194
|
+
await store(orgA, ' needs normalising ');
|
|
195
|
+
await store(orgB, ' so does this ');
|
|
196
|
+
const trace = await runJobs(${name.camel}, {});
|
|
197
|
+
// \`completed\`, not retried: a handler the table refuses retries its first page for ever.
|
|
198
|
+
expect(trace.executions.map((run) => run.outcome)).toEqual(['completed']);
|
|
199
|
+
expect(await titlesOf(orgA)).toEqual(['needs normalising']);
|
|
200
|
+
expect(await titlesOf(orgB)).toEqual(['so does this']);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
unitTest('the pass writes the projection and nothing else', async ({ runJobs }) => {
|
|
204
|
+
const stored = await store(orgA, ' padded ');
|
|
205
|
+
await runJobs(${name.camel}, {});
|
|
206
|
+
const [swept] = await inOrg(orgA, () => db.${feature.plural}.all());
|
|
207
|
+
expect(swept).toEqualRow({ ...stored, title: 'padded' });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
unitTest('the projection is idempotent: a replayed page changes nothing', () => {
|
|
211
|
+
// At least once is the contract: an attempt cancelled between the last row and its checkpoint
|
|
212
|
+
// hands this page to the next attempt. Twice through must equal once through.
|
|
213
|
+
const row: ${feature.pascal} = {
|
|
214
|
+
id: '00000000-0000-4000-8000-000000000001',
|
|
215
|
+
orgId: orgA,
|
|
216
|
+
title: ' needs normalising ',
|
|
217
|
+
price: { minor: 1000, currency: 'USD' },
|
|
218
|
+
createdAt: new Date(0),
|
|
219
|
+
};
|
|
220
|
+
const once = ${name.camel}Row(row);
|
|
221
|
+
expect(once.title).toBe('needs normalising');
|
|
222
|
+
expect(${name.camel}Row(once)).toEqual(once);
|
|
223
|
+
});
|
|
224
|
+
`;
|
|
225
|
+
|
|
226
|
+
export interface BackfillOptions extends FeatureTarget {
|
|
227
|
+
/** Every locale the planted entity's admin labels ship for. Defaults to `['en']`. */
|
|
228
|
+
readonly locales?: readonly string[];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function backfillFiles(rawName: string, target: BackfillOptions): readonly GeneratedFile[] {
|
|
201
232
|
const name = names(rawName);
|
|
202
233
|
const feature = names(target.feature);
|
|
203
234
|
const dir = `${target.surfaceDir}/${target.feature}/backfills`;
|
|
235
|
+
const dbModule = target.dbModule ?? PLACEHOLDER_DB_MODULE;
|
|
204
236
|
return [
|
|
205
|
-
// A sweep is a chain over the entity's own table
|
|
206
|
-
// the entity is what it reads and what its generated test
|
|
207
|
-
// `repo.ts` rides along with `entity.ts`: it is that file's only reader.
|
|
208
|
-
...sliceFoundation(target, ['entity']),
|
|
209
|
-
{ path: `${dir}/${name.kebab}.ts`, contents: backfillSource(name, feature) },
|
|
210
|
-
// A sweep IS a job (`backfill()` is a job factory)
|
|
211
|
-
// types a test by its filename
|
|
212
|
-
{ path: `${dir}/${name.kebab}.job.test.ts`, contents: backfillTest(name
|
|
237
|
+
// A sweep is a chain over the entity's own table, reached through the app's typed handle
|
|
238
|
+
// (`db.<table>`), so the entity is what it reads and what its generated test stores rows of.
|
|
239
|
+
// No repo call, but `repo.ts` rides along with `entity.ts`: it is that file's only reader.
|
|
240
|
+
...sliceFoundation(target, ['entity'], target.locales),
|
|
241
|
+
{ path: `${dir}/${name.kebab}.ts`, contents: backfillSource(name, feature, dbModule) },
|
|
242
|
+
// A sweep IS a job (`backfill()` is a job factory): its durable identity is a `jobTest`, and
|
|
243
|
+
// the gate types a test by its filename.
|
|
244
|
+
{ path: `${dir}/${name.kebab}.job.test.ts`, contents: backfillTest(name) },
|
|
245
|
+
// And the pass itself on the `unit` step, against the in-memory driver.
|
|
246
|
+
{ path: `${dir}/${name.kebab}.test.ts`, contents: backfillUnitTest(name, feature, dbModule) },
|
|
213
247
|
];
|
|
214
248
|
}
|
package/src/templates/entity.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// `x g entity <name>` — a table, its domain type and its invariants, plus the repo that owns the
|
|
2
|
-
// only DB access for the feature
|
|
3
|
-
// generator output is typed, diffable and testable from a
|
|
2
|
+
// only DB access for the feature and reads through the app's typed handle. Emitted as strings
|
|
3
|
+
// rather than copied fixture files so the generator output is typed, diffable and testable from a
|
|
4
|
+
// unit test.
|
|
4
5
|
|
|
6
|
+
import { sortedImports } from './imports';
|
|
5
7
|
import type { GeneratedFile, NameSet } from './naming';
|
|
6
8
|
import { names } from './naming';
|
|
7
|
-
import {
|
|
9
|
+
import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
|
|
10
|
+
import { LINE_WIDTH, wrapImport, wrapList } from './wrap';
|
|
8
11
|
|
|
9
12
|
/** The columns that leave the server. One list, read by the declaration and by its test. */
|
|
10
13
|
const VIEW_KEYS: readonly string[] = ["'id'", "'title'", "'price'", "'createdAt'"];
|
|
@@ -13,6 +16,12 @@ export interface FeatureTarget {
|
|
|
13
16
|
/** `apps/web/app` or `apps/web/site` — the surface the feature lives in. */
|
|
14
17
|
readonly surfaceDir: string;
|
|
15
18
|
readonly feature: string;
|
|
19
|
+
/**
|
|
20
|
+
* The app's own db package, `@<app>/db` — where the generated `repo.ts` imports the typed handle
|
|
21
|
+
* from. Supplied by `x g` (read off `packages/db/package.json`) and by `x new`; absent only in a
|
|
22
|
+
* pure call that names no app, which gets `PLACEHOLDER_DB_MODULE`.
|
|
23
|
+
*/
|
|
24
|
+
readonly dbModule?: string;
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
const entitySource = (
|
|
@@ -30,10 +39,10 @@ export const ${name.camel} = entity('${table}', {
|
|
|
30
39
|
//
|
|
31
40
|
// SINGLE-TENANT APP? There is no flag: \`x g action\`, \`x g query\` and \`x g policy\` decide on
|
|
32
41
|
// \`orgId\` too, so the edit is per slice and it is this: delete \`tenant: 'orgId'\` and the
|
|
33
|
-
// \`orgId: uuid()\` column below
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
42
|
+
// \`orgId: uuid()\` column below and drop \`'orgId'\` from \`indexes\`. repo.ts needs no edit —
|
|
43
|
+
// it names no org. entity.test.ts then expects \`$tenantColumn\` null and \`orgScoped\` false,
|
|
44
|
+
// its \`row()\` fixture and repo.test.ts's \`draft()\` lose \`orgId\`, and repo.test.ts loses its
|
|
45
|
+
// last test. Nothing else reads the column.
|
|
37
46
|
tenant: 'orgId',
|
|
38
47
|
columns: {
|
|
39
48
|
id: uuid().primaryKey(),
|
|
@@ -64,67 +73,152 @@ ${wrapList('', `export const ${name.pascal}View = ${name.camel}.$view([`, VIEW_K
|
|
|
64
73
|
export type ${name.pascal}View = typeof ${name.pascal}View.$row;
|
|
65
74
|
`;
|
|
66
75
|
|
|
67
|
-
|
|
76
|
+
/**
|
|
77
|
+
* A fluent read the way Biome prints it: on one line while it fits, one call per line when it does
|
|
78
|
+
* not. The name decides which — `db.creditNoteAttachments` breaks a chain `db.posts` does not — so
|
|
79
|
+
* a fixed shape is a red `lint` over code nobody typed.
|
|
80
|
+
*/
|
|
81
|
+
const chain = (indent: string, head: string, calls: readonly string[]): string => {
|
|
82
|
+
const joined = `${indent}${head}${calls.join('')};`;
|
|
83
|
+
if (joined.length <= LINE_WIDTH) return joined;
|
|
84
|
+
return `${indent}${head}\n${calls.map((call) => `${indent} ${call}`).join('\n')};`;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const repoSource = (name: NameSet, dbModule: string): string => {
|
|
68
88
|
const row = name.pascal;
|
|
69
|
-
const
|
|
70
|
-
' ',
|
|
71
|
-
'const row = await db().one<Physical>(',
|
|
72
|
-
[`sql\`select * from ${table} where id = \${id}\``],
|
|
73
|
-
');',
|
|
74
|
-
);
|
|
89
|
+
const table = `db.${name.plural}`;
|
|
75
90
|
const listSignature = wrapList(
|
|
76
91
|
'',
|
|
77
|
-
'export async function
|
|
78
|
-
['
|
|
92
|
+
'export async function list(',
|
|
93
|
+
['limit = 50'],
|
|
79
94
|
`): Promise<readonly ${row}[]> {`,
|
|
80
95
|
);
|
|
96
|
+
const byIdSignature = wrapList(
|
|
97
|
+
'',
|
|
98
|
+
'export async function byId(',
|
|
99
|
+
['id: string'],
|
|
100
|
+
`): Promise<${row} | undefined> {`,
|
|
101
|
+
);
|
|
102
|
+
const byIdBody = ` return (await ${table}.where({ id }).one()) ?? undefined;`;
|
|
81
103
|
const insertSignature = wrapList(
|
|
82
104
|
'',
|
|
83
105
|
'export async function insert(',
|
|
84
106
|
[`row: Omit<${row}, 'id' | 'createdAt'>`],
|
|
85
107
|
`): Promise<${row}> {`,
|
|
86
108
|
);
|
|
109
|
+
const listChain = chain(' ', `return ${table}`, [
|
|
110
|
+
".orderBy('createdAt', 'desc')",
|
|
111
|
+
'.limit(limit)',
|
|
112
|
+
'.all()',
|
|
113
|
+
]);
|
|
87
114
|
return `// The only module allowed to query the ${name.pluralKebab} table. Routes call actions and
|
|
88
115
|
// queries; actions call services; services call this.
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
116
|
+
//
|
|
117
|
+
// Every statement goes through the typed handle (\`db.<table>\`), so a renamed column is a compile
|
|
118
|
+
// error here and there is no SQL text to keep in step with the entity. The handle owns the rest:
|
|
119
|
+
// tenancy (every read runs under the ACTOR's org, so no function here takes or names one), the
|
|
120
|
+
// codecs (money is one property; a sealed column is opened on read), keyset paging (\`.limit()\`,
|
|
121
|
+
// \`.after(cursor)\`) and the ambient transaction.
|
|
122
|
+
//
|
|
123
|
+
// Raw SQL has two homes and neither is this file: a \`query\`'s source and a migration. Reach for
|
|
124
|
+
// it here only for a statement the handle cannot express — a join, a window, a CTE.
|
|
95
125
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
type Physical = Readonly<Record<string, unknown>>;
|
|
126
|
+
import { db } from '${dbModule}';
|
|
127
|
+
import type { ${row} } from './entity';
|
|
99
128
|
|
|
100
|
-
|
|
101
|
-
${
|
|
102
|
-
return row === null ? undefined : decodeRow(${name.camel}, row);
|
|
129
|
+
${byIdSignature}
|
|
130
|
+
${byIdBody}
|
|
103
131
|
}
|
|
104
132
|
|
|
105
133
|
${listSignature}
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return rows.map((row) => decodeRow(${name.camel}, row));
|
|
134
|
+
// The actor's org and nobody else's: the handle scopes the read, so there is no org to pass.
|
|
135
|
+
// Ordered and bounded — an unordered page is a different page on every request — and the handle
|
|
136
|
+
// adds the primary key as the last sort key, so the order is total without \`id\` spelled here.
|
|
137
|
+
${listChain}
|
|
111
138
|
}
|
|
112
139
|
|
|
113
140
|
${insertSignature}
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
const created = await db().one<Physical>(sql\`
|
|
118
|
-
insert into ${table} (id, org_id, title, price_minor, price_currency, price_scale)
|
|
119
|
-
values (\${newId()}, \${row.orgId}, \${row.title}, \${row.price.minor}, \${row.price.currency},
|
|
120
|
-
\${row.price.scale ?? null})
|
|
121
|
-
returning *\`);
|
|
122
|
-
if (created === null) throw dbDrift('${table}', 'id');
|
|
123
|
-
return decodeRow(${name.camel}, created);
|
|
141
|
+
// \`id\` and \`createdAt\` are the declaration's defaults, filled by the handle. The row names its
|
|
142
|
+
// org, and one that is not the actor's is refused (X_TENANCY_ACTOR_MISMATCH).
|
|
143
|
+
return ${table}.insert(row);
|
|
124
144
|
}
|
|
125
145
|
`;
|
|
126
146
|
};
|
|
127
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The repo's own test, emitted beside it: a generated file with no test is uncovered source in an
|
|
150
|
+
* app whose gate holds a coverage floor. Runs against the in-memory driver — the same contract
|
|
151
|
+
* Postgres serves — so it needs no database.
|
|
152
|
+
*/
|
|
153
|
+
const repoTest = (
|
|
154
|
+
name: NameSet,
|
|
155
|
+
dbModule: string,
|
|
156
|
+
): string => `// The ${name.kebab} repo against the in-memory driver: the contract Postgres serves, with no
|
|
157
|
+
// database. What it pins is what the repo cannot show by being read — whose rows a call reaches.
|
|
158
|
+
${sortedImports([
|
|
159
|
+
"import { createContext, frozenClock, runWithContext } from '@ultimat3/core';",
|
|
160
|
+
"import { testActor } from '@ultimat3/policy';",
|
|
161
|
+
"import { afterEach, expect, unitTest } from '@ultimat3/testing';",
|
|
162
|
+
`import { driver } from '${dbModule}';`,
|
|
163
|
+
])}
|
|
164
|
+
import * as repo from './repo';
|
|
165
|
+
|
|
166
|
+
const orgId = '00000000-0000-4000-8000-000000000002';
|
|
167
|
+
const otherOrg = '00000000-0000-4000-8000-000000000009';
|
|
168
|
+
|
|
169
|
+
// The request clock, so \`createdAt\` is an instant this file chose and "newest" is decidable.
|
|
170
|
+
const clock = frozenClock('2026-01-01T00:00:00.000Z');
|
|
171
|
+
|
|
172
|
+
/** What a request is to the handle: an actor, whose org every read and write runs under. */
|
|
173
|
+
const inOrg = <T>(org: string, run: () => Promise<T>): Promise<T> =>
|
|
174
|
+
runWithContext(createContext({ actor: testActor('member', { orgId: org }).actor, clock }), run);
|
|
175
|
+
|
|
176
|
+
const draft = (title: string) => ({ orgId, title, price: { minor: 1200, currency: 'USD' } });
|
|
177
|
+
|
|
178
|
+
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
179
|
+
afterEach(() => {
|
|
180
|
+
driver.reset?.();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
unitTest('insert stores the row and byId reads it back', async () => {
|
|
184
|
+
const stored = await inOrg(orgId, () => repo.insert(draft('first')));
|
|
185
|
+
// Neither was supplied: the entity's own defaults filled both.
|
|
186
|
+
expect(stored.id).toHaveLength(36);
|
|
187
|
+
expect(stored.createdAt).toEqual(clock.now());
|
|
188
|
+
expect(stored.price).toEqual({ minor: 1200, currency: 'USD' });
|
|
189
|
+
// \`toEqualRow\`, never \`toEqual\`: a \`.sealed()\` column is a non-enumerable property of a row,
|
|
190
|
+
// so \`toEqual\` would call two rows equal that hold different secrets.
|
|
191
|
+
expect(await inOrg(orgId, () => repo.byId(stored.id))).toEqualRow(stored);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
unitTest('byId answers undefined for an id nothing holds', async () => {
|
|
195
|
+
const absent = '00000000-0000-4000-8000-0000000000ff';
|
|
196
|
+
expect(await inOrg(orgId, () => repo.byId(absent))).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
unitTest('list is newest first and bounded', async () => {
|
|
200
|
+
for (const title of ['first', 'second', 'third']) {
|
|
201
|
+
await inOrg(orgId, () => repo.insert(draft(title)));
|
|
202
|
+
clock.advance(1000);
|
|
203
|
+
}
|
|
204
|
+
const page = await inOrg(orgId, () => repo.list(2));
|
|
205
|
+
expect(page.map((row) => row.title)).toEqual(['third', 'second']);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
unitTest('an actor in another org never reads the row, and no call names an org', async () => {
|
|
209
|
+
const stored = await inOrg(orgId, () => repo.insert(draft('private')));
|
|
210
|
+
// Neither read takes an org: the handle scopes both to the actor it runs under.
|
|
211
|
+
expect(await inOrg(otherOrg, () => repo.byId(stored.id))).toBeUndefined();
|
|
212
|
+
expect(await inOrg(otherOrg, () => repo.list())).toEqual([]);
|
|
213
|
+
expect(await inOrg(orgId, () => repo.list())).toHaveLength(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
unitTest('a read with no actor is refused rather than answered for every org', async () => {
|
|
217
|
+
const refused = await repo.list().catch((error: unknown) => error);
|
|
218
|
+
expect(refused).toBeUltimateError('X_TENANCY_UNSCOPED');
|
|
219
|
+
});
|
|
220
|
+
`;
|
|
221
|
+
|
|
128
222
|
const entityTest = (
|
|
129
223
|
name: NameSet,
|
|
130
224
|
snake: string,
|
|
@@ -202,9 +296,11 @@ unitTest('${name.camel} parses a row through its own columns', () => {
|
|
|
202
296
|
export function entityFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] {
|
|
203
297
|
const name = names(rawName);
|
|
204
298
|
const dir = `${target.surfaceDir}/${target.feature}`;
|
|
299
|
+
const dbModule = target.dbModule ?? PLACEHOLDER_DB_MODULE;
|
|
205
300
|
return [
|
|
206
301
|
{ path: `${dir}/entity.ts`, contents: entitySource(name, name.snake, name.table) },
|
|
207
302
|
{ path: `${dir}/entity.test.ts`, contents: entityTest(name, name.snake, name.table) },
|
|
208
|
-
{ path: `${dir}/repo.ts`, contents: repoSource(name,
|
|
303
|
+
{ path: `${dir}/repo.ts`, contents: repoSource(name, dbModule) },
|
|
304
|
+
{ path: `${dir}/repo.test.ts`, contents: repoTest(name, dbModule) },
|
|
209
305
|
];
|
|
210
306
|
}
|
|
@@ -185,17 +185,10 @@ function layoutFinding(path: string, at: string, property: string, where: string
|
|
|
185
185
|
|
|
186
186
|
export const guard: Guard = {
|
|
187
187
|
summary: 'an animation moves transform and opacity, never the layout',
|
|
188
|
-
async check(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
absolute: false,
|
|
193
|
-
})) {
|
|
194
|
-
const path = entry.split('\\\\').join('/');
|
|
195
|
-
if (path.includes('node_modules/')) continue;
|
|
196
|
-
files.push({ path, scss: await Bun.file(\`\${root}/\${path}\`).text() });
|
|
197
|
-
}
|
|
198
|
-
return animatedLayoutProperties(files);
|
|
188
|
+
async check(_root, sources) {
|
|
189
|
+
// The run's ONE read of the stylesheets: every guard asking for this glob shares the walk.
|
|
190
|
+
const sheets = await sources.files('{apps,packages}/**/*.scss');
|
|
191
|
+
return animatedLayoutProperties(sheets.map((file) => ({ path: file.path, scss: file.text })));
|
|
199
192
|
},
|
|
200
193
|
};
|
|
201
194
|
`;
|
|
@@ -67,19 +67,15 @@ export function bareThrows(files: readonly SourceFile[]): readonly Finding[] {
|
|
|
67
67
|
|
|
68
68
|
export const guard: Guard = {
|
|
69
69
|
summary: 'a failure carries a code, a cause and an executable fix — never a bare Error',
|
|
70
|
-
async check(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (path.includes('node_modules/') || /\\.(?:test|d)\\.tsx?$/.test(path)) continue;
|
|
80
|
-
files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
|
|
81
|
-
}
|
|
82
|
-
return bareThrows(files);
|
|
70
|
+
async check(_root, sources) {
|
|
71
|
+
// A test states its verdict with \`expect.unreachable()\`, which the suite reports on its own
|
|
72
|
+
// terms. The run's ONE read of the source: every guard asking for this glob shares the walk.
|
|
73
|
+
const code = await sources.files('{apps,packages}/**/*.{ts,tsx}');
|
|
74
|
+
return bareThrows(
|
|
75
|
+
code
|
|
76
|
+
.filter((file) => !/\\.(?:test|d)\\.tsx?$/.test(file.path))
|
|
77
|
+
.map((file) => ({ path: file.path, source: file.text })),
|
|
78
|
+
);
|
|
83
79
|
},
|
|
84
80
|
};
|
|
85
81
|
`;
|
|
@@ -161,17 +161,10 @@ export function unreplacedFocusRings(files: readonly StyleFile[]): readonly Find
|
|
|
161
161
|
|
|
162
162
|
export const guard: Guard = {
|
|
163
163
|
summary: 'a stylesheet replaces the focus ring, never only removes it',
|
|
164
|
-
async check(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
absolute: false,
|
|
169
|
-
})) {
|
|
170
|
-
const path = entry.split('\\\\').join('/');
|
|
171
|
-
if (path.includes('node_modules/')) continue;
|
|
172
|
-
files.push({ path, scss: await Bun.file(\`\${root}/\${path}\`).text() });
|
|
173
|
-
}
|
|
174
|
-
return unreplacedFocusRings(files);
|
|
164
|
+
async check(_root, sources) {
|
|
165
|
+
// The run's ONE read of the stylesheets: every guard asking for this glob shares the walk.
|
|
166
|
+
const sheets = await sources.files('{apps,packages}/**/*.scss');
|
|
167
|
+
return unreplacedFocusRings(sheets.map((file) => ({ path: file.path, scss: file.text })));
|
|
175
168
|
},
|
|
176
169
|
};
|
|
177
170
|
`;
|
|
@@ -141,19 +141,19 @@ export function unsizedImages(files: readonly SourceFile[]): readonly Finding[]
|
|
|
141
141
|
|
|
142
142
|
export const guard: Guard = {
|
|
143
143
|
summary: 'an image declares its box, and the priority one is never lazy',
|
|
144
|
-
async check(
|
|
144
|
+
async check(_root, sources) {
|
|
145
145
|
const files: SourceFile[] = [];
|
|
146
146
|
// TWO globs, and the rule is that a brace ALTERNATIVE may not contain a \`/\`. Measured on Bun
|
|
147
147
|
// 1.4.0 against \`examples/dummy\`: \`{apps/*/{site,app},packages/*/src}/**/*.tsx\` and
|
|
148
148
|
// \`{apps/web,packages/ui}/**/*.tsx\` each match ZERO files, where \`apps/*/{site,app}/**/*.tsx\`
|
|
149
149
|
// matches 17 — so folding these into one line silently turns the guard off, which is worse than
|
|
150
|
-
// the hole it closes. A LEADING group is fine and
|
|
150
|
+
// the hole it closes. A LEADING group is fine and the stylesheet guards here rely on it:
|
|
151
151
|
// \`{apps,packages}/**/*.scss\` matches all 15.
|
|
152
152
|
for (const pattern of ['apps/*/{site,app}/**/*.tsx', 'packages/*/src/**/*.tsx']) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (
|
|
156
|
-
files.push({ path, source:
|
|
153
|
+
// The run's ONE read: every guard asking for these two globs shares the walk.
|
|
154
|
+
for (const file of await sources.files(pattern)) {
|
|
155
|
+
if (/\\.test\\.tsx?$/.test(file.path)) continue;
|
|
156
|
+
files.push({ path: file.path, source: file.text });
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
return unsizedImages(files);
|